目录
Responses 接口格式
非流式对话
流式输出
多轮对话
提交工具与调用
上传图片
教程地址:
https://docs.whuanle.cn/zh/learn_ai_agent
Responses 接口格式
前面介绍了 OpenAI 的 chat 接口协议格式,有很多概念已经介绍过了,所以在本章中主要介绍实际代码测试过程,不再重复介绍各类概念。
非流式对话
Responses 接口使用比较简单的,示例如下:
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())) }); var client = factory.GetResponsesClient(); ClientResult<ResponseResult> result = await client.CreateResponseAsync("qwen/qwen3.5-9b", "1+1=?"); ResponseResult response = result.Value; Console.WriteLine($"[ASSISTANT]: {response.GetOutputText()}");实际请求:
{"model":"qwen/qwen3.5-9b","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"1+1=?"}]}]}Responses 接口参数和结构跟/v1/chat/completions有所差异,但是角色等概念是一样的,因此这里就不展开太多说明了,因为官方 SDK 很多内容都不加注释,代码量也比较少,所以也不好讲解。
流式和非流式区别有两个点。
• 流式返回使用
AsyncCollectionResult<T>,非流式使用ClientResult。• 流式的
<T>类型 比较多,通过类型拆解可以获取数据,而非流式只有 ResponseResult 这个类型。
ResponseResult 类型让 AI 注释了一下:
public partial class ResponseResult { /// 获取或设置是否以后台模式处理本次响应。 /// 后台模式用于耗时较长的异步任务:请求会立即返回,响应在后台生成, /// 调用方可稍后再轮询/取回结果,而不必保持长连接等待。 [CodeGenMember("Background")] public bool? BackgroundModeEnabled { get; set; } /// 获取或设置终端用户的标识符,用于 OpenAI 侧的滥用监测。 [CodeGenMember("User")] public string EndUserId { get; set; } /// 获取或设置模型的推理(reasoning)行为配置。 /// 用于控制模型在作答前投入多少"思考",例如 <see cref="ResponseReasoningOptions.ReasoningEffortLevel"/> /// 表示推理力度(None/Minimal/Low/Medium/High),<see cref="ResponseReasoningOptions.ReasoningSummaryVerbosity"/> /// 表示推理摘要的详细程度(Auto/Concise/Detailed)。 [CodeGenMember("Reasoning")] public ResponseReasoningOptions ReasoningOptions { get; set; } /// 获取或设置本次响应输出 token 数的上限。 /// 达到该上限时响应会被截断,<see cref="Status"/> 会变为 <c>Incomplete</c>, /// 原因记录在 <see cref="IncompleteStatusDetails"/>(Reason = MaxOutputTokens)。 [CodeGenMember("MaxOutputTokens")] public int? MaxOutputTokenCount { get; set; } /// 获取或设置本次响应中工具调用次数的上限。 [CodeGenMember("MaxToolCalls")] public int? MaxToolCallCount { get; set; } /// 获取或设置文本输出的格式配置。 /// 通过 <see cref="ResponseTextOptions.TextFormat"/> 指定输出为纯文本、JSON 对象,或遵循某个 JSON Schema。 [CodeGenMember("Text")] public ResponseTextOptions TextOptions { get; set; } /// 获取或设置对上下文做截断的策略。 /// <see cref="ResponseTruncationMode.Auto"/> 表示模型自动截断以适配上下文窗口; /// <see cref="ResponseTruncationMode.Disabled"/> 表示不截断。 [CodeGenMember("Truncation")] public ResponseTruncationMode? TruncationMode { get; set; } /// 获取或设置当响应以 <see cref="ResponseStatus.Incomplete"/> 结束时,导致未完成的具体原因。 /// 可能的原因见 <see cref="ResponseIncompleteStatusDetails"/>,例如达到输出 token 上限或命中内容过滤。 [CodeGenMember("IncompleteDetails")] public ResponseIncompleteStatusDetails IncompleteStatusDetails { get; set; } /// 获取本次响应生成的输出项集合。 /// 这是模型回复的核心载体,按生成顺序排列,元素类型派生自 <see cref="ResponseItem"/>。 /// 常见的有 <c>MessageResponseItem</c>(文本/图片等消息)、工具调用项、推理项等。 /// 要快速拼接所有文本输出,直接调用 <see cref="GetOutputText"/>。 [CodeGenMember("Output")] public IList<ResponseItem> OutputItems { get; } /// 获取或设置是否允许模型在同一轮中并行发起多个工具调用。 [CodeGenMember("ParallelToolCalls")] public bool ParallelToolCallsEnabled { get; set; } /// 获取或设置模型在本次响应中选择工具的方式。 /// 例如自动选择(Auto)、不调用任何工具(None)、必须调用(Required), /// 或指定调用某个具体工具(如某个函数)。具体取值见 <see cref="ResponseToolChoice"/>。 [CodeGenMember("ToolChoice")] public ResponseToolChoice ToolChoice { get; set; } /// 获取或设置为每个输出 token 返回的"最高概率候选词"数量(top logprobs)。 /// 设置后可用于研究模型在每个位置的概率分布;不设置则不返回 logprobs 信息。 [CodeGenMember("TopLogprobs")] public int? TopLogProbabilityCount { get; set; } /// 获取或设置本次响应所属的会话(conversation)上下文信息。 /// 通过 <see cref="ResponseConversationOptions.ConversationId"/> 标识该响应归属的会话, /// 用于基于 Conversation 的状态化多轮交互。 [CodeGenMember("Conversation")] public ResponseConversationOptions ConversationOptions { get; set; } /// 资源的类型标识,对 <see cref="ResponseResult"/> 始终为 <c>"response"</c>。 [CodeGenMember("Object")] [EditorBrowsable(EditorBrowsableState.Never)] public string Object { get; set; } = "response"; /// 获取本次响应所使用的系统/开发者指令(instructions)。 /// OpenAI 返回的指令可能是纯字符串,也可能是 <see cref="ResponseItem"/> 数组, /// 这里统一以 <see cref="ResponseItem"/> 列表的形式暴露,以兼容两种形态。 [CodeGenMember("Instructions")] public IList<ResponseItem> Instructions { get; } }流式输出
跟 Chat Completions 一样,Responses 也支持流式输出,日常使用基本都是用这种。
调用前需要在CreateResponseOptions里把StreamingEnabled设为true,然后用CreateResponseStreamingAsync,返回一个AsyncCollectionResult<StreamingResponseUpdate>,同样用await foreach逐条消费。
这一步比 Chat 严格:如果忘了把
StreamingEnabled设成true,SDK 会直接抛InvalidOperationException,提示你去调用 streaming 版本。反过来,非流式的CreateResponse也不允许把StreamingEnabled设成true。
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())) }); ResponsesClient client = factory.GetResponsesClient(); CreateResponseOptions options = new("qwen/qwen3.5-9b", [ResponseItem.CreateUserMessageItem("太阳系有多少颗行星?")]) { StreamingEnabled = true, }; AsyncCollectionResult<StreamingResponseUpdate> updates = client.CreateResponseStreamingAsync(options); Console.Write("[助手] "); await foreach (StreamingResponseUpdate update in updates) { if(update is StreamingResponseCreatedUpdate streamingResponseCreatedUpdate) { Console.WriteLine("Statr"); } if(update is StreamingResponseReasoningTextDeltaUpdate reasoningResponseItem) { Console.Write(reasoningResponseItem.Delta); } if (update is StreamingResponseOutputTextDeltaUpdate textUpdate) { Console.Write(textUpdate.Delta); } }实际请求会带上stream: true:
{ "model": "qwen/qwen3.5-9b", "input": [{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "太阳系有多少颗行星?" }] }], "stream": true }跟 Chat 的delta里塞reasoning_content/content不同,Responses 的 SSE 事件是按类型分发的:每条流式消息对应一个StreamingResponseUpdate的子类,你只需要is判断一次,就知道它代表什么。常用的几类:
更新类型 | 作用 |
|---|---|
StreamingResponseCreatedUpdate | 响应已创建,带初始的 |
StreamingResponseOutputItemAddedUpdate | 一个新的输出项开始生成(一条消息、一次工具调用等) |
StreamingResponseOutputTextDeltaUpdate | 正文文本增量,真正的“逐字输出”在这里,取 |
StreamingResponseOutputTextDoneUpdate | 正文文本片段结束, |
StreamingResponseReasoningTextDeltaUpdate | 深度思考(reasoning)正文增量 |
StreamingResponseReasoningSummaryTextDeltaUpdate | 深度思考的摘要增量 |
StreamingResponseFunctionCallArgumentsDeltaUpdate | 工具调用参数的增量(参数也是流式拼出来的) |
StreamingResponseOutputItemDoneUpdate | 一个输出项生成完毕, |
StreamingResponseCompletedUpdate | 整个响应结束,含完整的 |
StreamingResponseIncompleteUpdate | 因 token 上限或内容过滤提前结束 |
StreamingResponseErrorUpdate | 出错了 |
所以 Chat 里那种手写 JSON 去捞reasoning_content的做法,在 Responses 这里不需要了,直接判断类型即可:
await foreach (StreamingResponseUpdate update in updates) { switch (update) { case StreamingResponseReasoningTextDeltaUpdate reasoning: Console.Write(reasoning.Delta); break; case StreamingResponseOutputTextDeltaUpdate text: Console.Write(text.Delta); break; } }流式过程中,如果要把结果回填到下一轮的
input里,不要自己拼,等StreamingResponseOutputItemDoneUpdate触发,直接拿它的.Item(已经是完整的ResponseItem)加进InputItems就行,这是官方示例的做法,能避免增量拼接错位的问题。
多轮对话
Responses 多轮对话也是有两种思路。
第一种:服务端托管。OpenAI 的 Responses 接口本身是有状态的——每一次响应都会返回一个id(response.XXXXX),下一次请求只要带上previousResponseId,服务端会自动把上一次的输入输出接上,本地不需要维护messages数组。
这对工具调用场景尤其友好,因为工具调用产生的function_call/function_call_output项会被服务端一起记住。
ResponsesClient client = factory.GetResponsesClient(); // 第一轮 ResponseResult first = await client.CreateResponseAsync("qwen/qwen3.5-9b", "1+1=?"); Console.WriteLine($"[ASSISTANT]: {first.GetOutputText()}"); // 第二轮,带上 previousResponseId 即可,不必自己拼历史 ResponseResult second = await client.CreateResponseAsync( model: "qwen/qwen3.5-9b", inputItems: [ResponseItem.CreateUserMessageItem("再加上 2 呢")], previousResponseId: first.Id); Console.WriteLine($"[ASSISTANT]: {second.GetOutputText()}");第二种:本地拼装。如果你想完全自己控制上下文(比如走第三方兼容服务、或者要裁剪历史),就仿照 Chat 的写法,把上一轮的输出项整个塞回input:
List<ResponseItem> inputItems = [ ResponseItem.CreateUserMessageItem("1+1=?"), ]; ResponseResult first = await client.CreateResponseAsync(new CreateResponseOptions("qwen/qwen3.5-9b", inputItems)); // 把模型这一轮的输出项(含 assistant 消息)追加到 input 里 inputItems.AddRange(first.OutputItems); inputItems.Add(ResponseItem.CreateUserMessageItem("再加上 2 呢")); ResponseResult second = await client.CreateResponseAsync(new CreateResponseOptions("qwen/qwen3.5-9b", inputItems));注意第二种方式里我们用的是
inputItems.AddRange(first.OutputItems)——OutputItems里不仅有 assistant 文本消息,可能还包含 reasoning、工具调用等项,整个原样回填是安全的,模型会自己忽略不需要的部分。如果你在对接的是非 OpenAI 官方的兼容服务(比如本地 ollama/lm-studio 之类的),建议优先用本地拼装,previousResponseId不一定被支持。
提交工具与调用
Responses 的工具调用整体流程跟 Chat 一样(模型返回要调的函数 → 本地执行 → 把结果回填 → 再请求一次),但写法要清爽很多,原因有两个:
1.不用维护
role:tool消息。回填结果用的是专门的FunctionCallOutputResponseItem,它和函数调用项FunctionCallResponseItem天然配对,靠CallId关联,不需要塞进某种角色的content里。2.判断结束不用看
FinishReason。直接遍历response.OutputItems,看到FunctionCallResponseItem就执行、回填、再来一轮,循环到输出里没有函数调用为止。
我们沿用 Chat 提交工具与调用 的灯控例子,函数定义不变:
static Dictionary<int, bool> Lights = new() { { 1, false }, { 2, false }, { 3, false } }; static IReadOnlyDictionary<int, bool> GetLightState() => Lights; static IReadOnlyDictionary<int, bool> OpenOrCloseLight(int index, bool state) { Lights[index] = state; return GetLightState(); }定义工具用的是ResponseTool.CreateFunctionTool,跟 Chat 的ChatTool.CreateFunctionTool几乎一样,多了一个strictModeEnabled(开启后模型会严格按 schema 生成参数,建议关掉以便本地兼容服务):
FunctionTool getLightState = ResponseTool.CreateFunctionTool( functionName: nameof(GetLightState), functionDescription: "获取所有灯的状态", functionParameters: null, strictModeEnabled: false); FunctionTool openOrCloseLight = ResponseTool.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()), strictModeEnabled: false);多轮执行循环,注意和 Chat 版本的对比:
ResponsesClient client = factory.GetResponsesClient(); List<ResponseItem> inputItems = [ResponseItem.CreateUserMessageItem("获取所有灯的状态,并把 1、3 号的灯打开")]; bool requiresAction; do { requiresAction = false; CreateResponseOptions options = new("mimo-v2.5-pro", inputItems) { Tools = { getLightState, openOrCloseLight }, }; ResponseResult response = await client.CreateResponseAsync(options); // 关键点:把这一轮的输出项整个回填到 input 里 // 这样 function_call 和它对应的 function_call_output 自然成对出现在历史中 inputItems.AddRange(response.OutputItems); foreach (ResponseItem outputItem in response.OutputItems) { if (outputItem is FunctionCallResponseItem functionCall) { switch (functionCall.FunctionName) { case nameof(GetLightState): { string result = JsonSerializer.Serialize(GetLightState()); // 用 CallId 把输出和调用配对,不再需要 role:tool inputItems.Add(new FunctionCallOutputResponseItem(functionCall.CallId, result)); break; } case nameof(OpenOrCloseLight): { using JsonDocument args = JsonDocument.Parse(functionCall.FunctionArguments); int index = args.RootElement.GetProperty("index").GetInt32(); bool state = args.RootElement.GetProperty("state").GetBoolean(); string result = JsonSerializer.Serialize(OpenOrCloseLight(index, state)); inputItems.Add(new FunctionCallOutputResponseItem(functionCall.CallId, result)); break; } default: throw new NotImplementedException(); } requiresAction = true; } } } while (requiresAction); // 最后打印一下 assistant 的消息 foreach (ResponseItem item in inputItems) { if (item is MessageResponseItem msg && msg.Role == MessageRole.Assistant && msg.Content.Count > 0) { Console.WriteLine($"[ASSISTANT]: {msg.Content[0].Text}"); } }实际请求(第一次):
{ "model": "mimo-v2.5-pro", "input": [{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "获取所有灯的状态,并把 1、3 号的灯打开" }] }], "tools": [ { "type": "function", "name": "GetLightState", "description": "获取所有灯的状态" }, { "type": "function", "name": "OpenOrCloseLight", "description": "打开或关闭灯", "parameters": { "type": "object", "properties": { "...": "..." }, "required": ["index", "state"] } } ] }注意 Responses 的 schema 跟 Chat 不太一样:Chat 里函数信息是嵌在
function字段下({"type":"function","function":{"name":...}}),Responses 直接把name、description、parameters平铺在工具对象上({"type":"function","name":...}),少了一层嵌套。
模型返回的输出项里会出现一条函数调用:
{ "type": "function_call", "id": "fc_xxx", "call_id": "call_xxx", "name": "OpenOrCloseLight", "arguments": "{\"index\":1,\"state\":true}" }你执行完,构造一条FunctionCallOutputResponseItem,它的call_id必须和上面的call_id对上:
{ "type": "function_call_output", "call_id": "call_xxx", "output": "{\"1\":true,\"2\":false,\"3\":false}" }跟 Chat 对比一下,几个让人省心的点:
•没有
FinishReason判断。直接看OutputItems里有没有FunctionCallResponseItem即可,有就继续循环,没有就结束。•配对靠
CallId,不是消息顺序。回填用new FunctionCallOutputResponseItem(functionCall.CallId, output),不用操心往哪个角色后面插。•整体历史就是一份
input数组。function_call和function_call_output都是数组里的一项,模型一眼就能看懂“我之前调了什么、得到了什么”,语义比role:tool清晰得多。
如果是流式 + 工具调用,套路一样,只是改用
CreateResponseStreamingAsync,并在StreamingResponseOutputItemDoneUpdate里把.Item加回options.InputItems、判断它是不是FunctionCallResponseItem,完整示例可以参考官方仓库的Example04_FunctionCallingStreamingAsync.cs。
上传图片
Responses 里附图跟 Chat 原理相同——把用户消息的content从单个字符串换成ResponseContentPart数组,混排文字和图片——只是构造方法和类型名换了。
ResponsesClient client = factory.GetResponsesClient(); using Stream imageStream = File.OpenRead("34e7caa2-2852-458d-96cc-babff3c65c03.png"); BinaryData imageBytes = BinaryData.FromStream(imageStream); // 注意:BinaryData 必须带 MediaType,否则 SDK 会抛异常 imageBytes.MediaType = "image/png"; List<ResponseItem> inputItems = [ ResponseItem.CreateUserMessageItem( [ ResponseContentPart.CreateInputTextPart("识别图片内容."), ResponseContentPart.CreateInputImagePart(imageBytes), ]), ]; ResponseResult response = await client.CreateResponseAsync(new CreateResponseOptions("qwen/qwen3.5-9b", inputItems)); Console.WriteLine($"[ASSISTANT]: {response.GetOutputText()}");ResponseContentPart常用的几个工厂方法:
方法 | 说明 |
|---|---|
CreateInputTextPart(string) | 一段文字 |
CreateInputImagePart(BinaryData, detail?) | 本地图片字节(必须设置 |
CreateInputImagePart(Uri, detail?) | 公开可访问的图片 URL |
CreateInputImagePart(string fileId, detail?) | 先用文件接口上传得到的 |
实际请求里,图片被打包成input_image类型:
{ "model": "qwen/qwen3.5-9b", "input": [{ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "识别图片内容." }, { "type": "input_image", "image_url": "data:image/png;base64,iVBORw0..." } ] }] }Chat 里图片类型叫image_url,Responses 里改叫input_image,字段也从image_url.url简化成image_url(直接就是字符串),这是两套接口命名风格上的一个小区别,对接兼容服务时留意一下即可。
引入地址