数据截至 (上游 commit ceade4cbe9f2)
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. 顶层全景(它大概怎么转)
谁负责什么
这套编排由几个角色协作,职责分得很清:
| 角色 | 干什么 | 在哪个文件 |
|---|---|---|
BuiltinResponsesImpl | Provider 外壳,把 API 请求解包成参数 | src/ogx/providers/inline/responses/builtin/impl.py:99 |
OpenAIResponsesImpl | 门面:输入拼接、落库、后台/压缩、同步会话 | src/ogx/providers/inline/responses/builtin/responses/openai_responses.py:125 |
StreamingResponseOrchestrator | 真正的循环引擎:调模型 ↔ 分离工具 ↔ 执行 ↔ 回喂 | src/ogx/providers/inline/responses/builtin/responses/streaming.py:249 |
ToolExecutor | 具体执行一个工具调用(RAG/MCP/内置),见 05 | src/ogx/providers/inline/responses/builtin/responses/tool_executor.py:117 |
ChatCompletionContext / ToolContext | 贯穿整个循环的共享状态(消息、工具、审批) | src/ogx/providers/inline/responses/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)。