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 | 响应已创建,带初始的 ResponseResult(含响应 id 等) |
StreamingResponseOutputItemAddedUpdate | 一个新的输出项开始生成(一条消息、一次工具调用等) |
StreamingResponseOutputTextDeltaUpdate | 正文文本增量,真正的“逐字输出”在这里,取 .Delta |
StreamingResponseOutputTextDoneUpdate | 正文文本片段结束,.Text 是这一段的完整内容 |
StreamingResponseReasoningTextDeltaUpdate | 深度思考(reasoning)正文增量 |
StreamingResponseReasoningSummaryTextDeltaUpdate | 深度思考的摘要增量 |
StreamingResponseFunctionCallArgumentsDeltaUpdate | 工具调用参数的增量(参数也是流式拼出来的) |
StreamingResponseOutputItemDoneUpdate | 一个输出项生成完毕,.Item 是该输出项的完整对象 |
StreamingResponseCompletedUpdate | 整个响应结束,含完整的 ResponseResult 和 Usage |
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 一样(模型返回要调的函数 → 本地执行 → 把结果回填 → 再请求一次),但写法要清爽很多,原因有两个:
- 不用维护
role:tool消息。回填结果用的是专门的FunctionCallOutputResponseItem,它和函数调用项FunctionCallResponseItem天然配对,靠CallId关联,不需要塞进某种角色的content里。 - 判断结束不用看
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?) | 本地图片字节(必须设置 BinaryData.MediaType,内部会转成 data:image/png;base64,...) |
CreateInputImagePart(Uri, detail?) | 公开可访问的图片 URL |
CreateInputImagePart(string fileId, detail?) | 先用文件接口上传得到的 file_id |
实际请求里,图片被打包成 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(直接就是字符串),这是两套接口命名风格上的一个小区别,对接兼容服务时留意一下即可。