Chat Completions 接口
/v1/chat/completions 目前依然是 OpenAI 接口的主流,虽然跟 Responses 接口结构有所差异,但是很多概念相通的,所以本文重点介绍 Chat Completions 接口,很多概念在 下一章 中就不需要重复介绍了。
/v1/chat/completions 它的核心是通过 messages 数组记录对话上下文历史记录,每一项是一个 role + content,按时间顺序拼出完整对话。
角色有四种:
system:系统设定,告诉模型“你是谁、怎么回答”,整段对话里通常只出现一次。user:用户的话。assistant:模型之前的回答(用于多轮上下文)。tool:工具调用的返回结果(配合工具调用使用)。
OpenAI 的 SDK 内置了简化的角色消息类型,例如 UserChatMessage,使用示例:
List<ChatMessage> messages =
[
new UserChatMessage("1+1=?"),
];
其它的还有 SystemChatMessage / UserChatMessage / AssistantChatMessage 等。
/v1/chat/completions 常用的请求参数:
| 参数 | 说明 |
|---|---|
model | 模型名,如 gpt-4o |
messages | 对话历史数组 |
temperature | 采样温度,0~2,越大越随机 |
max_tokens | 回答的最大 token 数 |
stream | 是否流式返回 |
不同厂商的模型虽然支持 OpenAI 接口,但是在 temperature、max_tokens 等这些参数上不一定都有用。
如果想设置 temperature、token 上限等参数,传入第二个参数 ChatCompletionOptions:
ChatCompletionOptions options = new()
{
Temperature = 0.7f,
MaxOutputTokenCount = 200,
};
ChatCompletion completion = client.CompleteChat(messages, options);
对话里面如果没有带上 UserChatMessage,服务器可能会报错,例如在只有 SystemChatMessage 时,服务器报错:
List<ChatMessage> messages =
[
new SystemChatMessage("1+1=?"),
];

非流式对话
普通对话即非流式对话,直接等待服务器响应结束后返回,不过不建议使用此种方式,仅用于学习和调试。
笔者实测,如果使用此模式等待模型完全回答后才返回,在本地机器不佳的情况下,很容易导致服务器超时或故障。所以一次性对话的方式最好只是用于测试,日常应该使用流式对话。
OpenAIClient factory = new(
credential: new ApiKeyCredential("1234"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1234/v1"),
Transport = new HttpClientPipelineTransport(new HttpClient(new LoggingHandler()))
});
ChatClient client = factory.GetChatClient("qwen/qwen3.5-9b");
ChatCompletion completion = await client.CompleteChatAsync("1+1=?");
Console.WriteLine($"[ASSISTANT]: {completion.Content[0].Text}");
实际请求内容:
{"messages":[{"role":"user","content":"1+1=?"}],"model":"qwen/qwen3.5-9b"}
如果下一次对话需要拼接上下文历史对话记录:
OpenAIClient factory = new(
credential: new ApiKeyCredential("1234"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1234/v1"),
Transport = new HttpClientPipelineTransport(new HttpClient(new LoggingHandler()))
});
ChatClient client = factory.GetChatClient("qwen/qwen3.5-9b");
List<ChatMessage> messages =
[
new UserChatMessage("1+1=?"),
];
ChatCompletion completion = await client.CompleteChatAsync(messages);
Console.WriteLine("1+1=?");
Console.WriteLine($"[ASSISTANT]: {completion.Content[0].Text}");
// 拼装多轮对话
messages.Add(new AssistantChatMessage(completion.Content));
messages.Add(new UserChatMessage("再加上 2 呢"));
// 再次提问
completion = await client.CompleteChatAsync(messages);
Console.WriteLine("再加上 2 呢");
Console.WriteLine($"[ASSISTANT]: {completion.Content[0].Text}");
第二轮实际请求内容:
{"messages":[{"role":"user","content":"1+1=?。"},{"role":"assistant","content":"2"},{"role":"user","content":"再加上 2 呢"}],"model":"qwen/qwen3.5-9b"}
这里先列出几个要点,后续章节会使用到这些知识。
SystemChatMessage/UserChatMessage/AssistantChatMessage分别对应三种角色,都继承自抽象类ChatMessage。UserChatMessage提供了从string的隐式转换,所以也可以写成ChatMessage msg = "你好";。- 返回的
ChatCompletion把 HTTP 响应里的choices[0]展平了,文本内容用completion.Content[0].Text读取。
ChatCompletion 还有几个常用属性:
FinishReason:结束原因,Stop(正常结束)、Length(达到 token 上限)、ToolCalls(要求调用工具)、ContentFilter(被内容过滤)。Usage:token 用量统计,含InputTokenCount/OutputTokenCount/TotalTokenCount。Role:固定为assistant。
这里暂时还没有介绍流式输出,所以后续会详细介绍这些参数。
流式对话
聊天类场景几乎使用流式对话,主要是对用户交互友好,本小节会详细讲解流式对话如何处理一些细节。
对于 http 请求来说只需要把 stream 设为 true 即可,但是代码处理起来则会麻烦一些,SDK 里对应 CompleteChatStreamingAsync ,返回一个可 await foreach 的集合,每次拿到一小段增量文本,比较讲究处理技巧。
案例:
OpenAIClient factory = new(
credential: new ApiKeyCredential("1234"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1234/v1"),
Transport = new HttpClientPipelineTransport(new HttpClient(new LoggingHandler()))
});
ChatClient client = factory.GetChatClient("qwen/qwen3.5-9b");
AsyncCollectionResult<StreamingChatCompletionUpdate> updates =
client.CompleteChatStreamingAsync("太阳系有多少颗行星?");
Console.Write("[助手] ");
await foreach (StreamingChatCompletionUpdate update in updates)
{
if (update.ContentUpdate.Count > 0)
{
Console.Write(update.ContentUpdate[0].Text);
}
}
实际请求:
{"messages":[{"role":"user","content":"太阳系有多少颗行星?"}],"model":"qwen/qwen3.5-9b","stream":true,"stream_options":{"include_usage":true}}
对话时,AI 会进行深度思考,其返回结构如下:
{
... ...
"choices": [{
"index": 0,
"delta": {
"reasoning_content": "太阳系"
},
"logprobs": null,
"finish_reason": null
}]
}
reasoning_content 就是深度思考的内容。
返回的流式的其中一条结果:
{
... ...
"choices": [{
"index": 0,
"delta": {
"content": "行星"
},
"logprobs": null,
"finish_reason": null
}]
}
content 就是正文内容,当然后续还会讲解到 tool call ,这些都会塞到 choices 里面。
如果对话正常,最后两条条返回的结构:
{
"choices": [{
"index": 0,
"delta": {},
"logprobs": null,
"finish_reason": "stop"
}]
}
{
"choices": [],
"usage": {
"prompt_tokens": 15,
"completion_tokens": 1001,
"total_tokens": 1016,
"completion_tokens_details": {
"reasoning_tokens": 822
}
}
}

服务器会首先返回 "finish_reason": "stop 表示当前对话已经结束,然后最后返回 Usage 统计此对话消耗的 token 数量,prompt_tokens 是输入的问题或上下文提问的 tokens,completion_tokens 是 AI 回答消耗的 tokens,reasoning_tokens 是深度思考消耗的 tokens。
不过目前 SDK 只提供了 audio, content, function_call, tool_calls, role, refusal,没有提供 reasoning_content,所以深度思考的内容需要自己另外解析出来。
ChatClient client = factory.GetChatClient("qwen/qwen3.5-9b");
AsyncCollectionResult<StreamingChatCompletionUpdate> updates = client.CompleteChatStreamingAsync("太阳系有多少颗行星");
Console.Write("[助手] ");
await foreach (StreamingChatCompletionUpdate update in updates)
{
// 深度思考内容(Qwen/DeepSeek 等厂商扩展字段,通常先于正文到达)
string reasoning = GetReasoningContent(update);
if (!string.IsNullOrEmpty(reasoning))
{
Console.Write(reasoning);
}
// 正文
if (update.ContentUpdate.Count > 0)
{
Console.Write(update.ContentUpdate[0].Text);
}
if (update.ContentUpdate.Count > 0)
{
Console.Write(update.ContentUpdate[0].Text);
}
}
static string GetReasoningContent(StreamingChatCompletionUpdate update)
{
BinaryData json = ModelReaderWriter.Write(update);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("choices", out JsonElement choices)
&& choices.GetArrayLength() > 0)
{
JsonElement delta = choices[0].GetProperty("delta");
if (delta.ValueKind == JsonValueKind.Object
&& delta.TryGetProperty("reasoning_content", out JsonElement rc)
&& rc.ValueKind == JsonValueKind.String)
{
return rc.GetString();
}
}
return null;
}

如果碰到 tool call,情况则会复杂很多,涉及到 agent 多轮对话,会更加麻烦,这些我们后续的章节再讨论。
提交工具与调用
OpenAI SDK 没有提供执行 tool/function call 的引擎,所以提交 tool/function call 和执行比较麻烦,使用 Microsoft Agent Framework 这些框架会更加方便一些。不过既然本文介绍 OpenAI 接口原理,就不能怕麻烦,只能硬着头皮写案例。
注意,OpenAI 的 Function Calling 已经被废弃,虽然一些 SDK 里面还有
fuction_call字段,但是一般都是空的,现在都是使用tool_call字段。但是一般交流还是使用 Function Call,所以无论使用哪个表达,读者清楚即可,实际上解析结构时我们使用的
tool_call字段。
工具调用(Function Calling)让大模型不只是 “说话”,还能 “调用外部能力”,例如查数据库、调 API、执行本地函数,这是 Agent 的基础能力。
在使用 Codex、Claude Code 等 AI Agent 时,它们可以帮助我们执行命令行、读取网页内容,但是这些功能并不是大模型直接执行的,大模型服务器只返回要执行的工具,然后本地服务就需要解析返回的结构,执行具体的命令,所以这就要求我们编写 Agent 时具有 Function Calling 执行引擎。
大体流程如下:
OpenAI 提供了 Assistants、Chat 、Responses 三种执行 Function Calling 的方式,这些对应的服务器接口地址都不一样,我们这里只讲 Chat 方式。
首先定义函数:
static Dictionary<int, bool> Lights = new Dictionary<int, bool>
{
{ 1, false },
{ 2, false },
{ 3, false }
};
static IReadOnlyDictionary<int, bool> GetLightState()
{
return Lights;
}
static IReadOnlyDictionary<int, bool> OpenOrCloseLight(int index, bool state)
{
Lights[index] = state;
return GetLightState();
}
创建 Chat Tool:
注意,type 只支持 json 的类型,
string、number、integer、boolean、object、array、null,要按 json 格式处理,否则会报错。
// 定义 function call
ChatTool getLightState = ChatTool.CreateFunctionTool(
functionName: nameof(GetLightState),
functionDescription: "获取所有灯的状态");
ChatTool openOrCloseLight = ChatTool.CreateFunctionTool(
functionName: nameof(OpenOrCloseLight),
functionDescription: "打开或关闭灯",
functionParameters: BinaryData.FromBytes("""
{
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "light index"
},
"state": {
"type": "boolean",
"description": "open or close light."
}
},
"required": [ "index","state" ]
}
"""u8.ToArray()));
ChatCompletionOptions options = new()
{
Tools = { getLightState, openOrCloseLight },
};
多轮对话解析内容,这里处理起来非常麻烦,需要自己解析 json。
OpenAIClient factory = new(
credential: new ApiKeyCredential("1234"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1234/v1"),
Transport = new HttpClientPipelineTransport(new HttpClient(new LoggingHandler()))
});
ChatClient client = factory.GetChatClient("mimo-v2.5-pro");
List<ChatMessage> messages = [new UserChatMessage("获取所有灯的状态,并把 1、3 号的灯打开")];
bool requiresAction;
// 多轮对话,简单的 Agent
do
{
requiresAction = false;
ChatCompletion completion = await client.CompleteChatAsync(messages, options);
switch (completion.FinishReason)
{
case ChatFinishReason.Stop:
{
messages.Add(new AssistantChatMessage(completion));
break;
}
case ChatFinishReason.ToolCalls:
{
messages.Add(new AssistantChatMessage(completion));
foreach (ChatToolCall toolCall in completion.ToolCalls)
{
switch (toolCall.FunctionName)
{
case nameof(GetLightState):
{
IReadOnlyDictionary<int, bool> toolResult = GetLightState();
messages.Add(new ToolChatMessage(toolCall.Id, JsonSerializer.Serialize(toolResult)));
break;
}
case nameof(OpenOrCloseLight):
{
using JsonDocument argumentsJson = JsonDocument.Parse(toolCall.FunctionArguments);
bool v1 = argumentsJson.RootElement.TryGetProperty("index", out JsonElement index);
bool v2 = argumentsJson.RootElement.TryGetProperty("state", out JsonElement state);
if (!v1 || !v2)
{
throw new ArgumentNullException(nameof(index), "The index argument is required.");
}
var toolResult = OpenOrCloseLight(index.GetInt32(), state.GetBoolean());
messages.Add(new ToolChatMessage(toolCall.Id, JsonSerializer.Serialize(toolResult)));
break;
}
default:
{
// Handle other unexpected calls.
throw new NotImplementedException();
}
}
}
requiresAction = true;
break;
}
case ChatFinishReason.Length:
throw new NotImplementedException("Incomplete model output due to MaxTokens parameter or token limit exceeded.");
case ChatFinishReason.ContentFilter:
throw new NotImplementedException("Omitted content due to a content filter flag.");
case ChatFinishReason.FunctionCall:
throw new NotImplementedException("Deprecated in favor of tool calls.");
default:
throw new NotImplementedException(completion.FinishReason.ToString());
}
} while (requiresAction);
foreach (ChatMessage message in messages)
{
switch (message)
{
case UserChatMessage userMessage:
Console.WriteLine($"[USER]:");
Console.WriteLine($"{userMessage.Content[0].Text}");
Console.WriteLine();
break;
case AssistantChatMessage assistantMessage when assistantMessage.Content.Count > 0:
Console.WriteLine($"[ASSISTANT]:");
Console.WriteLine($"{assistantMessage.Content[0].Text}");
Console.WriteLine();
break;
case ToolChatMessage:
// Do not print any tool messages; let the assistant summarize the tool results instead.
break;
default:
break;
}
}
}
实际请求:
{
"messages": [{
"role": "user",
"content": "获取所有灯的状态,并把 1、3 号的灯打开"
}],
"model": "mimo-v2.5-pro",
"tools": [{
"type": "function",
"function": {
"description": "获取所有灯的状态",
"name": "GetLightState"
}
}, {
"type": "function",
"function": {
"description": "打开或关闭灯",
"name": "OpenOrCloseLight",
"parameters": {
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "light index"
},
"state": {
"type": "boolean",
"description": "open or close light."
}
},
"required": ["index", "state"]
}
}
}]
}
当模型要调用时,响应的 finish_reason 会变成 tool_calls,choices[0].message.tool_calls 里带函数名和参数(参数是 JSON 字符串);你执行函数后,把结果以 role:"tool" 的消息追加进 messages,再请求一次即可。
有几个需要注意的地方:
- 参数是字符串,不是对象。
toolCall.FunctionArguments永远是 JSON 字符串,需要自己解析,模型也可能 “幻觉” 出错误参数,所以解析前要做校验。 toolCall.Id必须配对。回填ToolChatMessage时第一个参数必须是触发它的toolCall.Id,模型靠它把调用和结果对应起来。- 必须用循环。回填工具结果后要再次调用
CompleteChat,模型才会基于结果生成自然语言回答——所以整体是do-while。 - 多工具时,模型可能一次返回多个
tool_calls,要把它们都执行完再回填再请求。
Responses 接口(见后文)的工具调用模型略有不同,不需要
role:tool,而是用FunctionCallResponseItem/FunctionCallOutputResponseItem,写起来更简洁。
上传图片
本节演示如何在一个对话中附加图片。

图片内容:

原理是在 messages 里把 user 消息的 content 从普通字符串改成数组结构,混合文字和图片。
图片有两种给法:url 直接给一个公开可访问的图片链接,或用 data:image/png;base64,... 把图片以 base64 内嵌进请求。支持视觉的模型(如 gpt-4o)会同时读取文字和图片。
OpenAIClient factory = new(
credential: new ApiKeyCredential("tp-ciejsuv3ipa3x01iud5jbg21yeg8rhl9hxdgw10od0p13l5y"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1234/v1"),
Transport = new HttpClientPipelineTransport(new HttpClient(new LoggingHandler()))
});
ChatClient client = factory.GetChatClient("qwen/qwen3.5-9b");
using Stream imageStream = File.OpenRead("34e7caa2-2852-458d-96cc-babff3c65c03.png");
BinaryData imageBytes = BinaryData.FromStream(imageStream);
List<ChatMessage> messages =
[
new UserChatMessage(
ChatMessageContentPart.CreateTextPart("识别图片内容."),
ChatMessageContentPart.CreateImagePart(imageBytes, "image/png")),
];
ChatCompletion completion = await client.CompleteChatAsync(messages);
Console.WriteLine($"[ASSISTANT]: {completion.Content[0].Text}");
实际请求:
{
"messages": [{
"role": "user",
"content": [{
"type": "text",
"text": "识别图片内容."
}, {
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0... ..."
}
}]
}],
"model": "qwen/qwen3.5-9b"
}
其它语音合成等内容这里就不展开了。