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=?"),
];

image-20260710145514913


非流式对话

普通对话即非流式对话,直接等待服务器响应结束后返回,不过不建议使用此种方式,仅用于学习和调试。

笔者实测,如果使用此模式等待模型完全回答后才返回,在本地机器不佳的情况下,很容易导致服务器超时或故障。所以一次性对话的方式最好只是用于测试,日常应该使用流式对话。

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
		}
	}
}

image-20260710152127098


服务器会首先返回 "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;
}

image-20260713091658468


如果碰到 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 执行引擎。


大体流程如下:

正在渲染 Mermaid 图表...

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 的类型,stringnumberintegerbooleanobjectarraynull,要按 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_callschoices[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,写起来更简洁。


上传图片

本节演示如何在一个对话中附加图片。

image-20260713105227419


图片内容:

34e7caa2-2852-458d-96cc-babff3c65c03


原理是在 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"
}

其它语音合成等内容这里就不展开了。