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整个响应结束,含完整的 ResponseResultUsage
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 接口本身是有状态的——每一次响应都会返回一个 idresponse.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 直接把 namedescriptionparameters 平铺在工具对象上({"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_callfunction_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(直接就是字符串),这是两套接口命名风格上的一个小区别,对接兼容服务时留意一下即可。