跳到主要内容

Responses API:服务端 agentic 编排循环(皇冠明珠)

30 秒导读: 普通的"chat completion"只回一次话;真正的 agent 要「问模型 → 模型想调工具 → 执行工具 → 把结果喂回模型 → 再问一次……」转好几圈才收尾。OGX 把这整个循环放进了服务端,客户端只发一次请求,就能以流式事件看到中间每一步,循环跑完再落库。这一章讲的就是这个循环怎么转。

本章是 OGX 的核心价值所在。前面几章讲了请求怎么进来(01)、provider 怎么装配(02)、一次模型调用怎么落到后端(03)。这一章往上走一层:多次模型调用 + 工具执行,如何被编排成一个自动循环。具体工具怎么执行(RAG / MCP / 函数)和后台响应、自动压缩,留给 05


1. 这是什么(零基础也能懂)

先分清两个概念

  • Chat Completion(对话补全):你给模型一段对话,模型回你一段话。一问一答,一次结束。这是最底层的原语。
  • Response(响应):你给模型一个任务和一堆工具,模型可能"想一下 → 调个搜索工具 → 看结果 → 再调一个 → 最后才回答"。一个 Response 内部可能包含很多次 chat completion 和很多次工具执行

OpenAI 在 2024 年推出的 Responses API 就是把后者标准化。OGX 实现了这套 API,而且是在自己的服务端完整跑这个循环,而不是把循环甩给客户端。

它解决什么问题 / 给谁用

假设你在写一个"能查资料再回答"的 agent。如果只有 chat completion,你得自己在客户端写这样的循环:

# 示意,非源码:客户端自己扛 agent 循环的痛苦
while True:
resp = call_model(messages, tools) # 问模型
if not resp.tool_calls: # 模型不想调工具了
break
for call in resp.tool_calls: # 逐个执行工具
result = run_tool(call)
messages.append(tool_result_message(result))
messages.append(resp.assistant_message) # 把这轮也记进历史
# 期间还得自己管:流式、用量统计、历史存储、并发工具……

这段循环又臭又长又容易写错:流式怎么拼?多轮的历史怎么攒?工具调用怎么分类(客户端函数 vs 服务端内置 vs MCP)?用量怎么累加?

OGX 的 Responses API 把这一整坨收进服务端。客户端只需:

# 示意,非源码:客户端只发一次请求
response = client.responses.create(
model="gpt-4o",
input="东京今天天气如何?顺便查下汇率",
tools=[web_search_tool, mcp_tool],
stream=True, # 想看中间过程就开流
)
for event in response: # 服务端把循环的每一步作为事件推过来
print(event.type) # response.created / output_text.delta / ...

用户对象是后端工程师和应用开发者:他们已经在用 OpenAI SDK,想把代码指到 OGX 上"直接就能跑",还想换后端模型(vLLM / Ollama / Bedrock…)不改业务代码。

一句话直觉

把它想成一个餐厅后厨的传菜循环:客户(客户端)只下一次单;后厨(服务端编排器)自己在"问主厨(模型)→ 主厨说还差个食材 → 跑去拿(工具)→ 回来再问主厨"之间来回跑,期间不断把"上菜进度"(流式事件)端给客户看,直到主厨说"齐活了"才收尾结账(落库)。


2. 顶层全景(它大概怎么转)

谁负责什么

这套编排由几个角色协作,职责分得很清:

角色干什么在哪个文件
BuiltinResponsesImplProvider 外壳,把 API 请求解包成参数.../builtin/impl.py:63
OpenAIResponsesImpl门面:输入拼接、落库、后台/压缩、同步会话.../builtin/responses/openai_responses.py:106
StreamingResponseOrchestrator真正的循环引擎:调模型 ↔ 分离工具 ↔ 执行 ↔ 回喂.../builtin/responses/streaming.py:224
ToolExecutor具体执行一个工具调用(RAG/MCP/内置),见 05.../builtin/responses/tool_executor.py:72
ChatCompletionContext / ToolContext贯穿整个循环的共享状态(消息、工具、审批).../builtin/responses/types.py:184 / :98

下文所有 .../ 均指 src/ogx/providers/inline/responses/

一次请求的高层走向

从请求进来到事件流出去,大致这样(从上到下是时间顺序):

客户端 responses.create(input, tools, stream)


① BuiltinResponsesImpl.create_openai_response impl.py:125
│ 解包 CreateResponseRequest → 关键字参数

② OpenAIResponsesImpl.create_openai_response openai_responses.py:619
│ 校验参数;background? → 走后台队列(见05)
│ 否则 → _create_streaming_response(...)

③ _create_streaming_response openai_responses.py:1058
│ 输入拼接(接上一条响应/会话/prompt/instructions)
│ 组装 ChatCompletionContext
│ new StreamingResponseOrchestrator

④ orchestrator.create_response() ←── 皇冠明珠的循环 ──→ streaming.py:406
│ 逐个 yield 事件

⑤ 每个事件:_persist_streaming_state 落库 + yield 给上层 openai_responses.py:526
│ 终态事件时:_sync_response_to_conversation openai_responses.py:1567

客户端逐事件收到(stream=True),或聚合成一个终态对象(stream=False)

一句话记住:②③是"准备",④是"跑循环",⑤是"边跑边存边推"

为什么整条链都是"流式生成器"

注意 ②create_openai_response 无论客户端要不要流,内部总是先拿到 _create_streaming_response异步生成器(openai_responses.py:740)。区别只在最后:

  • stream=True:把生成器原样返回,事件逐个吐给客户端(openai_responses.py:773)。
  • stream=False:服务端自己把生成器消费完,只挑出终态那一个 response.completed 返回(openai_responses.py:779-828)。

这样设计的好处:落库、会话同步这些副作用,不管客户端流不流都一定会发生——因为它们挂在生成器被消费的过程里,而不是循环之外一个单独的收尾步骤。


3. 核心原理:输入怎么拼(循环开始前)

模型是无状态的——它不记得"上一条响应"。所以循环开始前,服务端必须把完整历史拼成一串 messages 喂给模型。这一步在 _process_input_with_previous_response(openai_responses.py:268)。

三种历史来源

一个新请求的历史可能来自三处,代码用 if/elif 分三条路:

场景触发条件怎么拼
接上一条响应传了 previous_response_id取旧响应的 input + output,再接新 input
接一个会话传了 conversation从 conversation 拉历史 items
全新对话都没传只转换本次 input

场景一:接上一条响应

这是 agent 多轮的主路径。核心是 _prepend_previous_response(openai_responses.py:252):把旧响应的输入项 + 输出项摊平成一个列表,再把新 input 追加到尾部。

# 示意,非源码:把"上一条响应"整个接到新输入前面
new_items = list(previous_response.input) # 旧的输入
new_items.extend(previous_response.output) # 旧的输出(含工具调用、回答)
new_items.append(new_user_message) # 这次的新话

真实实现里有个关键优化:优先直接复用旧响应存好的 messages,只把新 input 转成 message 追加(openai_responses.py:294-301),避免每轮都从头重建整段 chat 历史。旧数据没有 messages 时才回退到全量重建(:302-304)。

同时,ToolContext.recover_tools_from_previous_response(types.py:123)会把上一条响应里已经列过的 MCP 工具清单捡回来复用——省掉重复的 tools/list 调用。

场景二 / 三

  • 接会话(conversation):从 conversations_api.list_items 拉出历史,同样优先用存储的 messages 作真源,新 input 追加(openai_responses.py:307-338)。
  • 全新:直接 convert_response_input_to_chat_messages(input)(openai_responses.py:339-341)。

拼完还要"戴帽子":instructions 与 prompt

历史拼好后,_create_streaming_response 还会往 messages 最前面插系统消息:

  1. instructions(本次请求的系统指令):messages.insert(0, system_msg)(openai_responses.py:1113)。
  2. 可复用的 prompt 模板:_prepend_prompt(openai_responses.py:345)。它从 prompts_api 取模板,用正则 {{var}} 把变量替换掉(:403-408),文本变量直接填、图片/文件变量则生成一句 [Image: xxx] 占位并把真实媒体作为新 user message 追加(:387-415)。

拼装完成后,所有状态被塞进 ChatCompletionContext(openai_responses.py:1122-1134)——这个对象会贯穿整个循环


4. 核心原理:编排循环本身(皇冠明珠)

进入正题。StreamingResponseOrchestrator.create_response()(streaming.py:406)是一个 async 生成器,它 yield 出的每一个东西都是一个 OpenAI 风格的流式事件

4.1 循环怎么读:一张图

先建立整体心智模型。怎么读这张图:从上往下是一次 create_response 的生命周期;中间的方框是一个 while True 循环,命中"无工具调用"才跳出

create_response() streaming.py:406

├─ yield response.created 先告诉客户端"我开工了"
├─ yield response.in_progress

├─ [可选] 输入 guardrails 校验 run_guardrails(输入) streaming.py:422
│ └─ 命中 → yield 拒答 → return

├─ _process_tools() 准备工具清单 streaming.py:1717
│ └─ MCP tools/list、web_search、file_search 挂进 ctx.chat_tools


┌──────────────── while True(主循环) ─────────────────┐ streaming.py:489
│ │
│ ① 组装 params,调模型(流式) │ streaming.py:540-584
│ inference_api.openai_chat_completion(stream) │
│ │
│ ② _process_streaming_chunks() │ streaming.py:1024
│ 把 chunk 拼成完整回答 + 累加 usage │
│ 期间 yield: output_text.delta / 工具参数 delta │
│ │
│ ③ _separate_tool_calls() │ streaming.py:773
│ 把本轮工具调用分成:函数(客户端) / │
│ 非函数(服务端内置+MCP) / 待审批 │
│ │
│ ④ 有 reasoning? → 作为 reasoning item 先 yield 出去 │ streaming.py:631
│ │
│ ⑤ _coordinate_tool_execution() │ streaming.py:1427
│ 执行"非函数"工具,结果作为 tool message │
│ 追加到 next_turn_messages(准备回喂) │
│ │
│ ⑥ 收尾判断: │ streaming.py:697-721
│ ├─ 没有任何工具调用 → break(收工) │
│ ├─ 有"函数"(客户端)调用 → break(交给客户端) │
│ └─ 否则 n_iter++,超 max_infer_iters → incomplete│
│ │
│ messages = next_turn_messages(把结果喂回,再转一圈) │ streaming.py:696
└───────────────────────────────────────────────────────┘


yield response.completed / response.incomplete / response.failed streaming.py:754-771

4.2 循环三步,逐个拆

步骤 ① 调模型(流式)

每一圈,编排器都根据 ctx 组装一个 OpenAIChatCompletionRequestWithExtraBody(streaming.py:540),关键点:

  • stream=True + stream_options.include_usage=True:要流式,还要最后一块带上用量统计(streaming.py:536-538)。
  • 工具会按 allowed_tools 过滤(streaming.py:514-520);parallel_tool_calls 只在有工具时才传(streaming.py:531-533)。
  • 需要推理(reasoning)时优先走 openai_chat_completions_with_reasoning,provider 不支持就降级回普通补全(streaming.py:569-584)。

步骤 ② 拼 chunk + 累加用量

_process_streaming_chunks(streaming.py:1024)是循环里最长的一段。它做两件事:

  1. 把流式碎片拼成一个完整回答:文本内容累加进 chat_response_content,工具调用参数按 index 累加进 chat_response_tool_calls(streaming.py:1177-1247)。同时把每一小块作为 output_text.deltafunction_call_arguments.deltamcp_call_arguments.delta 等事件 yield 出去,客户端能实时看到打字机效果。
  2. 累加用量:每个 chunk 调 _accumulate_usage(streaming.py:863)。因为一个 Response 可能调好几次模型,用量必须跨调用累加——accumulated_usage 把每次的 prompt/completion/total tokens 加起来(streaming.py:886-900)。

这个方法最后不是普通地结束,而是 yield 一个 ChatCompletionResult(streaming.py:1385)——这是给循环主体用的"这一轮的完整结果",和给客户端的流式事件走同一个生成器、靠类型区分(streaming.py:588-592)。

步骤 ③ 分离工具调用

拿到完整回答后,_separate_tool_calls(streaming.py:773)把模型这一轮想调的工具分成三类,因为它们的后续处理完全不同:

类别判定谁执行
函数工具(function)是客户端注册的函数,或模型瞎编的名字返回给客户端执行
非函数工具web_search / file_search / 已知 MCP 工具服务端自己执行
待审批MCP 工具且 require_approval 命中先发审批请求,等下一轮

一个巧妙的容错:模型如果调了一个既不是注册函数、也不是内置、也不是 MCP 的工具名(即幻觉出来的名字),不会让服务器崩,而是当成客户端函数调用返回(streaming.py:809-823)——把未知情况优雅地推给客户端,而不是抛未处理异常。

这一步还顺便把这一轮的 assistant 消息(含工具调用)追加进 next_turn_messages(streaming.py:798),这就是"回喂"的载体。

步骤 ⑤ 执行工具并回喂

_coordinate_tool_execution(streaming.py:1427)执行非函数(服务端)工具。它逐个调 tool_executor.execute_tool_call(streaming.py:1495,细节见 05),把流式事件转发出去,把工具的输出作为 tool 角色消息追加进 next_turn_messages(streaming.py:1528-1529)。

函数(客户端)工具则不执行,只作为 function_call 输出项 yield 出去(streaming.py:1534-1563)——因为服务端没法执行客户端的代码。

4.3 循环怎么收尾

循环末尾的三个分支决定这一圈之后干嘛(streaming.py:697-721):

  • 没有任何工具调用break,模型给出了最终答案,循环结束。
  • 有函数(客户端)工具调用 → 也 break,把球踢给客户端(它执行完会带着结果发新请求接上)。
  • 否则(只有服务端工具)→ n_iter += 1,把工具结果 messages = next_turn_messages 回喂,再转一圈。转够 max_infer_iters(默认 10)还没停,就标记为 incomplete(原因 max_iterations_exceeded)。

还有几个提前收尾的守卫:max_output_tokens 达到(streaming.py:490-501)、模型 finish_reason == "length"(streaming.py:723-725)都会让状态变 incomplete

一个防死循环的细节:第一圈如果 tool_choice 是"强制调某工具",第二圈开始自动重置为 auto(streaming.py:710-711),否则会永远被逼着调工具、停不下来。


5. 核心原理:OpenAI 风格的事件序列

Responses API 的价值一半在这套流式事件协议——它必须和 OpenAI 逐字节对齐,客户端 SDK 才能"直接就能跑"。

事件的骨架

一次典型的(带一次工具调用的)响应,事件大致按这个顺序流出:

response.created ← 开工
response.in_progress
response.output_item.added (工具调用 item 出现)
response.function_call_arguments.delta × N (工具参数逐字节)
response.function_call_arguments.done
response.output_item.done
── 服务端执行工具、回喂、再调模型 ──
response.output_item.added (assistant 文本消息)
response.content_part.added
response.output_text.delta × N (回答逐字节)
response.content_part.done
response.output_item.done
response.completed ← 收工(带最终 response 对象 + usage)

每个事件都带一个单调递增的 sequence_number(streaming.py:294 初始化,全程 self.sequence_number += 1),客户端靠它保证顺序。

每次都发一个"响应快照"

response.created / in_progress / completed / incomplete / failed 这些"里程碑"事件,都带一个完整的 OpenAIResponseObject。它由 _snapshot_response(streaming.py:362)现造:根据当前状态和已累积的 output_messagesaccumulated_usage,拍一张当前进度的快照

这就是为什么客户端在流式中途 GET /v1/responses/{id} 也能看到"半成品"状态,而不是空结果。

安全阀:guardrails 在流中拦截

如果开了 guardrails,输出也要边流边查。_process_streaming_chunks 里,文本 delta 不直接 yield,而是先缓冲(streaming.py:1128-1129),每攒够 200 字符(_GUARDRAIL_BATCH_CHARS)就调一次 run_guardrails(streaming.py:1255-1271)。没问题才把缓冲的事件放出去;命中违规则清空缓冲、发一个拒答响应、return(streaming.py:1262-1267),后半段回答绝不外泄。


6. 核心原理:边跑边落库(增量持久化)

循环在 orchestrator.create_response() 里跑,但存储发生在上层 _create_streaming_response(openai_responses.py:1198-1236)。它一边消费编排器吐出的事件,一边在关键节点落库。

在哪些点存

_persist_streaming_state(openai_responses.py:526)按事件类型决定存不存:

事件存什么
response.in_progress初次 INSERT,output 为空
response.output_item.done增量 UPDATE:当前累积的 output + messages
response.completed / incomplete终态 UPDATE:完整状态
response.failed存错误状态,便于 GET 看到失败

好处很实在:客户端在流式过程中轮询 GET /v1/responses/{id} 就能看到进行中的 turn 状态,而不是等到全部结束(openai_responses.py:539-542 的注释点明了这个意图)。

一个省存储的技巧:增量输入

如果这次是接着 previous_response_id,历史已经存过了,没必要每轮再把整段历史重存一遍(那是 O(n²))。所以代码用 incremental 标志:接续且没触发压缩时,只存本轮新增的 input(openai_responses.py:1192-1196)。这把存储从 O(n²) 降到 O(n)。

终态时同步会话

如果请求带了 conversation,循环成功收尾时会把这一轮的输入 + 输出同步进会话:_sync_response_to_conversation(openai_responses.py:1567)把 input 和 output items 组装好,调 conversations_api.add_items 落进会话(openai_responses.py:1229-1234)。注意压缩项(OpenAIResponseCompaction)会被过滤掉不入会话(openai_responses.py:1579)。


7. 贯穿状态:ChatCompletionContext 与 ToolContext

循环能转起来,靠两个"随身背包"式的状态对象(都在 types.py)。

ChatCompletionContext(types.py:184)

装一次响应 turn 内、跨多次 chat completion 的累积状态:

  • messages:当前完整对话(每轮回喂后更新)。
  • chat_tools:转成 OpenAI 格式、真正传给模型的工具清单。
  • response_tools / tool_context:原始工具定义与工具上下文。
  • approval_requests / approval_responses:MCP 工具的审批记录——构造时就从 input 里把审批请求/回应分拣出来(types.py:227-231),approval_response() 供循环查"这个工具调用是否已获批"(types.py:233)。

ToolContext(types.py:98)

专管"工具复用"的账本,四个字段分工明确:

字段含义
current_tools本次请求传入的工具
previous_tools从上一条响应重建的"工具名 → MCP server"映射
previous_tool_listings上一条响应里可复用的 mcp-list-tools 对象
tools_to_process本次还需要重新处理的工具

它的核心方法 recover_tools_from_previous_response(types.py:123)做的就是:逐个比对当前 MCP 工具和上一条响应里的,allowed_tools 一样的就复用旧清单,不一样的才重新 tools/list(types.py:139-160)。这是 MCP 多轮对话省往返的关键。


8. 巧妙之处(可借鉴的技术)

  • 一条生成器同时承载"给客户端的事件"和"给循环的结果"。 _process_streaming_chunks 既 yield 流式事件、又 yield 一个 ChatCompletionResult,上层靠 isinstance 分流(streaming.py:588-592)。省掉了"回调 + 返回值"两套机制,一个 async generator 全搞定。

  • 流不流,副作用都发生。 因为落库/会话同步挂在生成器消费过程里,stream=False 时服务端自己消费完(openai_responses.py:779),stream=True 时客户端消费;无论哪种,存储都不会漏(openai_responses.py:1221-1223 的注释特意说明:即使消费者提前 break 也已存好)。

  • 幻觉工具名不崩服务。 模型编一个不存在的工具名,被当成客户端函数调用优雅退回(streaming.py:809-823),而不是抛异常。

  • 增量输入把存储从 O(n²) 降到 O(n)。 接续响应时只存新增 input(openai_responses.py:1189-1196)。

  • 第二圈强制把 tool_choice 重置为 auto。 防止"强制调工具"导致的无限循环(streaming.py:710-711)。

  • guardrails 攒批检查,违规不外泄。 输出 delta 先缓冲,每 200 字符查一次,命中就清缓冲发拒答(streaming.py:1251-1267)。


9. 边界与局限(诚实)

  • truncation="auto" 未实现。 只支持 disabled;传 auto 直接返回 failed,让 provider 自己拒绝超长上下文(streaming.py:436-448)。
  • streambackground 互斥。 同时传会报错(openai_responses.py:659);background 还必须 store=True(:662)。
  • 函数(客户端)工具无法服务端执行。 遇到就 break,把控制权交回客户端(streaming.py:700-702)——这是设计使然,不是缺陷,但意味着客户端函数会打断服务端循环。
  • 循环有硬上限。 max_infer_iters 默认 10、max_output_tokensmax_tool_calls 都会强制收尾为 incomplete,而非无限跑。
  • 用量统计依赖 provider 回传。 include_usage 若 provider 不支持,accumulated_usage 可能不准(代码尽力累加,见 streaming.py:857-900)。

10. 代码地图(导航索引)

按符号名可 grep 定位,比行号抗漂移。

主题文件路径符号名
Provider 外壳/解包请求.../builtin/impl.pyBuiltinResponsesImpl.create_openai_response
门面入口/参数校验/分流.../builtin/responses/openai_responses.pyOpenAIResponsesImpl.create_openai_response
组装 context 并启动编排.../builtin/responses/openai_responses.py_create_streaming_response
输入拼接(三种历史来源).../builtin/responses/openai_responses.py_process_input_with_previous_response
接上一条响应.../builtin/responses/openai_responses.py_prepend_previous_response
prompt 模板变量替换.../builtin/responses/openai_responses.py_prepend_prompt
增量持久化(边跑边存).../builtin/responses/openai_responses.py_persist_streaming_state
首次落库.../builtin/responses/openai_responses.py_store_response
终态同步进会话.../builtin/responses/openai_responses.py_sync_response_to_conversation
循环引擎(皇冠明珠).../builtin/responses/streaming.pyStreamingResponseOrchestrator.create_response
准备工具清单.../builtin/responses/streaming.py_process_tools
拼 chunk + yield 事件.../builtin/responses/streaming.py_process_streaming_chunks
跨调用累加用量.../builtin/responses/streaming.py_accumulate_usage
分离工具调用(三类).../builtin/responses/streaming.py_separate_tool_calls
执行工具并回喂.../builtin/responses/streaming.py_coordinate_tool_execution
造响应快照.../builtin/responses/streaming.py_snapshot_response
贯穿状态:对话上下文.../builtin/responses/types.pyChatCompletionContext
贯穿状态:工具复用账本.../builtin/responses/types.pyToolContext
一轮的完整结果载体.../builtin/responses/types.pyChatCompletionResult

相邻章节:上一步"一次模型调用怎么落到后端"见 03;本章循环里"工具具体怎么执行"以及后台响应、自动压缩见 05;存储与多租户见 06