一、先看一次“读取配置文件”为什么会停在半路
用户说“读取配置文件并告诉我数据库地址”。模型很快提出调用 read_file,但产品不能见到工具名就立刻执行:
这个路径是否允许读取?要不要先问用户?工具是在当前进程里跑,还是交给浏览器或远端 worker?
如果等待确认时页面刷新,回来以后还能不能继续?
这里先把问题限定准确:源码能直接证明的是同一个 Agent runtime 怎样暂停并接收确认结果。
浏览器重连、进程重启和 provider continuation 是另外三种恢复层级;只有外层服务把 pending tool state
与 reply 身份持久化并重新关联时,“刷新后继续”才成立。后文不会用一个 RedisStorage 类名替代这条端到端证据。
一个“输入文字、返回最终文字”的接口回答不了这些问题。应用必须看见中间状态,确认结果必须能回到原来的回复, 工具结果也必须和最初那次调用对应起来。AgentScope 的切入点,就是把“一次回复”从一轮模型调用提升成一段有状态的运行。
上一篇的 Pi 已经把 provider adapter、agent loop、tool result、event 和 session 树放进了最小 core。 AgentScope 不是推翻这条基线,而是在它上面继续接走产品责任:让中间过程可观察,让副作用可询问、可暂停, 再让同一条 reply 能被服务层和多用户应用复用。因此这一篇看的不是“另一种 loop”,而是最小 runtime 怎样长出治理边界。
二、先认识一次 reply 的五个要素
AgentScope 是一个把 agent 回复、工具动作和应用事件放进同一条运行线的框架。 为了理解这条线,先只记五个要素:
- Reply:从用户输入开始,到最终回答结束的一次完整处理;中间可以调用模型多次。
- Event:回复过程对外发出的进度,例如开始、模型输出、等待确认、工具结果和结束。
- Msg 账本:保存用户消息、模型内容、工具调用和工具结果,让下一轮知道刚才发生了什么。
- 模型适配:把同一份内部消息翻译成不同模型 API 要求的请求格式,再把返回值翻译回来。
- 工具关口:在真正执行前校验参数、检查权限,并决定允许、拒绝、询问用户或交给外部系统。
这五个要素不是五个孤立功能。Event 报告 Reply 的进度,工具关口可以暂停 Reply,确认结果让 Reply 恢复, 最终所有关键步骤都写进 Msg 账本。
三、一次 reply 怎样走完
- 用户消息先写入上下文,runtime 为这次回复分配一个
reply_id并发出开始事件。 - AgentScope 把 system prompt、必要历史和可用工具整理成统一的模型输入。
- 模型适配层把统一输入翻译成当前 provider 的格式,并把流式输出逐步还原成内部内容块。
- 模型提出
read_file后,runtime 发出工具调用事件,校验参数并进入权限判断。 - 如果需要确认,回复暂停;用户确认再次进入同一个
reply_stream,原来的工具调用继续执行。 - 工具结果写回 Msg 账本,模型读取结果继续回答;没有后续工具时,runtime 发出结束事件。
到这里再看 reply_stream 就不会只把它理解成“文字逐字输出”。它流动的是整个回复生命周期,
其中既有可展示的内容,也有会改变执行路径的事件。
阅读契约。
这篇跟一条 reply_stream 走到底:先看用户输入怎样变成结构化消息账本,
再看模型输出怎样变成事件和工具调用,最后看 Responses API 与 Chat Completions 两种 provider 形状
怎样被翻译回同一个 AgentScope runtime。
证据边界。
源码引用固定到 AgentScope 当前公开快照
ae9819017d6c195967a8f17b0ab4e70ff803f5a3。OpenAI API 语义引用官方
Responses 迁移指南。
文中“账本”“权限门”“服务平面”是对公开代码的工程归纳,不推断闭源控制台或模型 provider 内部实现。
这一篇只回答六个问题:
reply_stream到底承诺了什么,为什么它返回事件而不是最终字符串?- AgentScope 的内部账本是什么形状,
Msg和 content block 解决什么问题? - 模型调用前,system prompt、summary、context 和 tool schema 怎样进入统一输入?
- 工具调用为什么不是普通函数调用,而是一段可暂停的 lifecycle?
- AgentScope 支持 OpenAI Responses API 与 Chat Completions API 时,差异到底在哪里?
- 为什么一个 example service 就能看出它不是脚本框架?
四、reply_stream 是公开事件契约
如果一个 agent 接口只返回最终字符串,前端在中间过程里什么都看不到:不知道模型正在思考、准备调用工具、
等待用户确认,还是已经卡住。reply_stream 解决的是这个问题。它接收用户消息、确认结果或外部执行结果,
然后把 _reply 里不是最终 Msg 的对象持续 yield 出去。
对应用层来说,这相当于订阅一条可展示、可记录,也可供恢复层按身份重新关联的 typed event 流。
这里直接成立的是同一 Agent 实例里的 reply 恢复;浏览器重连和进程重启仍要求外层服务持久化事件游标、
pending tool state 与 reply 身份,不能只凭“有 typed event”推出跨进程恢复。
源码在
这里。
真正的状态推进在 _reply_impl。可以把它读成一条回复的检查清单:先判断这是不是上一轮等待中的确认或外部执行结果;
如果是新消息,就生成新的 reply_id、重置迭代计数,并发出 ReplyStartEvent。
接着进入 reasoning/acting 循环:没有待执行工具时先推理,有工具时执行;遇到用户确认或外部执行就暂停;
超过最大迭代数就发出 ExceedMaxItersEvent 和 ReplyEndEvent。
这段主线在
_reply_impl。
这就是 AgentScope 的第一个 owner:它拥有“回复过程”的生命周期,而不是只把模型输出转发出来。
EventType 也证明了这点:事件覆盖 reply、model call、text/data/thinking block、tool call、tool result、
max iters、用户确认和外部执行结果。
事件枚举
不是 UI 装饰,而是 runtime 对外的稳定边界。
| 外部看到 | 内部同时发生 | 为什么重要 |
|---|---|---|
ReplyStartEvent |
写入新的 reply_id,重置迭代计数。 |
UI、日志和持久化能按一次 reply 聚合事件。 |
ModelCallStart/End |
准备消息、工具 schema,收集 usage。 | 模型调用不再是黑盒,可以计量和追踪。 |
Text/Thinking/ToolCall block events |
把 provider streaming delta 还原成 content blocks。 | 前端能流式展示,后端能保存完整结构。 |
RequireUserConfirm |
工具状态先标记为 asking,再暂停循环。 | 用户确认不是弹窗逻辑,而是 agent 状态的一部分。 |
RequireExternalExecution |
外部工具状态先标记为 submitted,再等待结果事件。 | 浏览器、远端 worker、异步系统可以接管执行。 |
把这张表落到一次工具调用上,状态形状大概是下面这样。它是为了解释 owner 的简化形状,不是逐字段复刻源码:
reply_id: "reply_42"
iteration: 1
context += user Msg("请读取配置文件")
model chunk -> ToolCallBlock(
id: "call_7",
name: "read_file",
arguments: {"path": "config.toml"},
state: "created"
)
permission decision -> "ask"
pending_tool_call.state = "asking"
yield RequireUserConfirmEvent(reply_id, call_id)
用户确认后恢复 reply_stream
pending_tool_call.state = "executing"
tool result -> ToolResultBlock(call_id: "call_7", output: "...")
context += assistant Msg([ToolCallBlock, ToolResultBlock])
这样看,reply_stream 的价值就不只是“可以流式显示”。它让一次 reply 有自己的局部状态:
工具调用可以暂停,确认结果可以回灌,外部执行可以稍后返回,最终写进 Msg 账本后再进入下一轮模型输入。
4.1 同一个 call_7 的三条结局
| 决定 | 记录怎样变化 | 下一轮看到什么 |
|---|---|---|
| allow | state 进入 executing,本地或外部 owner 执行工具 | ToolResultBlock(call_7, output) |
| ask → confirm | 保存 asking 与确认事件,恢复时仍匹配 reply_42 / call_7 | 确认后才执行,并写回同一个 call id |
| deny 或参数无效 | 不执行,把拒绝或校验错误写成 tool result | 模型可以解释或修正,不能假装读到了文件 |
成功分支也要走到底:工具只返回 config.toml 中的 database.address,runtime
把结果匹配给 call_7,模型据此生成最终回答,并避免回显同文件里的其他密钥。
ReplyEndEvent 只说明 reply 生命周期结束;答案是否只暴露允许字段,仍需要业务校验器或调用方验收。
五、内部账本不是聊天字符串,而是 Msg 与 content blocks
AgentScope 没有把历史记录存成一串聊天字符串。它用 Msg 保存 sender name、role、content block、
message id、metadata、创建/完成时间和 usage。这样做看起来比 {role, content} 重,
但它解决了一个实际问题:下一轮模型不仅要看到文本,还要知道哪段是 thinking,哪段是工具调用,
哪段是工具结果,哪段是业务数据。
证据见
Msg。
| 普通聊天记录会混在一起 | AgentScope 拆成什么 | 为什么要拆 |
|---|---|---|
| 模型回答文本 | TextBlock |
可以流式展示,也可以进入下一轮上下文。 |
| 模型推理痕迹 | ThinkingBlock |
Responses API 里可能需要保留 reasoning item id,Chat 路径则可能跳过。 |
| “我要调用 read_file” | ToolCallBlock |
工具名、参数、call id 和状态需要被权限系统和 formatter 继续使用。 |
| 工具返回的内容 | ToolResultBlock |
下一轮模型要把结果和对应工具调用匹配起来。 |
| 图片、文件或结构化数据 | DataBlock |
不同 provider 的多模态 wire shape 不一样,内部账本要先保持统一。 |
模型调用前,_prepare_model_input 会把 system prompt、压缩 summary、当前 context 拼成 messages,
再从 Toolkit 取当前激活 tool groups 的 JSON schemas,返回 { messages, tools }。
这里还没有区分 OpenAI Responses 或 Chat Completions。Agent runtime 先产生 provider-neutral 的内部输入,
具体 wire shape 交给模型适配层。
源码在
_prepare_model_input。
Toolkit 也不是一个函数列表。它负责注册和管理 tool functions、MCP clients、Agent skills 和 tool groups;
它能从 docstring 或模型扩展生成 schema,也能通过统一 streaming interface 执行工具。
这解释了为什么 AgentScope 会把工具 schema 作为 runtime 输入,而不是让业务代码每次模型调用前手写。
Toolkit 注释
已经把这几个 owner 写得很直接。
六、工具调用是一段可治理的 lifecycle
在看函数前,先按 owner 把一次调用拆开。注册来源负责提供能力,Toolkit 规范化 schema,PermissionEngine 只作 policy 决定,真正执行可以在本地或外部系统,结果最后由 Agent loop 消费。权限 owner 与执行 owner 不是同一个概念。
tool function / MCP / skill
-> Toolkit registry + normalized schema
-> model emits ToolCallBlock(call_7)
-> argument validation
-> PermissionEngine: allow / ask / deny
-> local _acting OR RequireExternalExecutionEvent
-> ToolResultBlock(call_7)
-> Msg ledger -> next model-visible input
模型返回工具调用后,AgentScope 不会直接 call_tool。_execute_tool_call 先检查工具是否可用,
再用 schema 解析和校验 JSON 输入;失败时会把错误作为 tool result 写回上下文,让 agent 能在下一轮看到。
成功后才进入 PermissionEngine。这段入口在
_execute_tool_call。
权限引擎有独立模式。默认模式的顺序是 deny rules、ask rules、tool 自身权限检查、安全 ask、allow rules、最后默认 ask;
EXPLORE 模式则把只读作为硬边界,修改类调用直接拒绝。它不是“是否弹确认框”的 UI 参数,
而是一套会参与执行决策的 policy dispatcher。
对应源码在
默认模式
和
EXPLORE 模式。
如果决策是 ask 或 passthrough,AgentScope 会先把 tool call state 改成 asking,再 yield
RequireUserConfirmEvent 并返回。如果是 deny,则生成 denied tool result。如果 allow,
它发出 ToolResultStartEvent,外部工具会转成 RequireExternalExecutionEvent;
本地工具才进入 _acting,然后把流式 chunk 转成 tool result events,最后把压缩或截断后的结果写回 context。
这段 lifecycle 在
权限处理和工具结果回写。
关键区别。 function calling demo 通常只问“模型要调用哪个函数”。AgentScope 多问四个生产问题: 这个调用处于哪个状态,谁批准,在哪里执行,结果如何流式展示,最后怎样进入下一轮上下文。
七、Responses API 和 Chat Completions 的差别在哪里
先用一句话记住差别:Responses API 像一串 typed items,Chat Completions 像一串 chat messages。
同样是“用户说话、模型调用工具、工具返回结果”,Responses API 会把 message、reasoning、function_call、
function_call_output 都放成 item;Chat Completions 则继续使用 messages、assistant tool_calls 和 role: "tool" message。
AgentScope 同时支持两者,但没有把差异泄漏到 _reply_impl,而是隔离到两个模型类和两个 formatter。
Responses API 的输入形状
{
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "..."}]},
{"type": "reasoning", "id": "rs_...", "summary": []},
{"type": "function_call", "id": "fc_...", "call_id": "call_...", "name": "read_file", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_...", "output": "..."}
],
"tools": [{"type": "function", "name": "read_file", "parameters": {...}}]
}
Chat Completions 的输入形状
{
"messages": [
{"role": "user", "content": [{"type": "text", "text": "..."}]},
{"role": "assistant", "tool_calls": [{"id": "call_...", "type": "function", "function": {...}}]},
{"role": "tool", "tool_call_id": "call_...", "content": "..."}
],
"tools": [{"type": "function", "function": {"name": "read_file", "parameters": {...}}}]
}
AgentScope 的 Responses 适配器 OpenAIResponseModel 调用的是
client.responses.create,请求字段是 input,最大输出 token 字段是
max_output_tokens,reasoning 参数被包装成 {"effort": ...}。
它还会剔除 Chat Completions 风格的 modalities 和 audio 参数,因为这个 adapter
当前不支持 Responses 的音频输入输出路径。证据见
类注释
和
API 调用。
Chat 适配器 OpenAIChatModel 则调用 client.chat.completions.create,
请求字段是 messages,最大输出 token 字段是 max_tokens,thinking 参数名是
reasoning_effort。它还保留了 Chat Completions 的语音路径:设置 voice 时自动填
audio 和 modalities,streaming 时用 stream_options.include_usage。
源码在
OpenAIChatModel._call_api。
| 维度 | Responses API adapter | Chat Completions adapter | AgentScope 为什么要分开 |
|---|---|---|---|
| 请求入口 | client.responses.create(input=...) |
client.chat.completions.create(messages=...) |
wire field 不同,不能共用 formatter。 |
| 历史格式 | typed input items:input_text、input_image、function_call、function_call_output。 |
chat messages:content、tool_calls、role: tool。 |
同一个 Msg 账本要翻译成两套 provider 形状。 |
| reasoning | reasoning 是 output item;有 reasoning_item_id 时需要回放。 |
读 reasoning_content 或 reasoning 字段,历史里静默跳过 thinking block。 |
Responses 把 reasoning 当 item,Chat 把它当 message/chunk 字段。 |
| 工具调用 id | 区分 item id 与匹配结果用的 call_id。 |
主要使用 tool_call_id 对齐 assistant tool call 和 tool message。 |
Responses 的 function_call_output 必须能匹配正确 call。 |
| streaming | 处理 response.output_text.delta、response.output_item.added、response.function_call_arguments.delta、response.completed。 |
处理 choices[0].delta.content、delta.tool_calls、audio delta 和 usage chunk。 |
两边都归一成 ChatResponse,上层事件转换才能共用。 |
| 音频路径 | 当前 adapter 明确跳过/剔除音频相关参数。 | 支持 voice/audio,并把音频包装成 DataBlock。 |
能力差异在 adapter 层暴露,上层 runtime 不需要误以为完全等价。 |
formatter 的差异更关键。OpenAIResponseFormatter 明确写了几个转换:
文本块变 input_text,图片块变 input_image,assistant tool-call message
变顶层 function_call item,tool result 变 function_call_output item,
带 reasoning_item_id 的 thinking block 会被原样回放。
源码在
formatter 注释、
reasoning 与 function_call
和
function_call_output。
OpenAIChatFormatter 走另一条路:文本是 {"type": "text"},
tool call 放进 assistant message 的 tool_calls,tool result 变成 role: "tool"
的消息,thinking block 在聊天历史里被静默跳过。这在
OpenAIChatFormatter
里很清楚。
streaming 解析也分两种。Responses 适配器直接处理 typed event,把 response.output_text.delta
转成 TextBlock,把 response.output_item.added 里的 function_call
初始化成工具调用,把 response.function_call_arguments.delta 追加到参数里,最后在
response.completed 汇总 usage 和 reasoning item id。
源码见
Responses streaming parser。
Chat 适配器则从 choices[0].delta 读 text、reasoning、tool_calls 和 audio,最后同样产出
ChatResponse。
源码见
Chat streaming parser。
一个容易误读的点。
OpenAI Responses API 支持通过 previous_response_id 和 store 使用服务端上下文,也支持把 reasoning / function call
作为 typed output item 管理。AgentScope 这条 agent 主线里,显式 owner 仍是自己的 Msg context:
_prepare_model_input 先收集内部消息和工具,再由 Responses adapter 翻译成 input。
额外参数可以透传给 API,但 AgentScope 没有把 runtime 状态完全外包给 provider。
| 状态表面 | 谁拥有 | 它活到什么时候 |
|---|---|---|
| AgentScope ledger | Msg、context、summary 与 tool state 由 AgentScope runtime 管理。 |
跨模型调用存在,用于恢复 reply、准备下一轮输入和服务化 session。 |
| Provider request | formatter 把本轮 model-visible view 序列化成 input 或 messages。 |
服务于这次 API 调用;它是投影,不是 durable ledger。 |
| Responses server state | OpenAI 可通过 store 与 previous_response_id 保存并续接 response。 |
是可选的 provider continuation;不能替代 AgentScope 的权限、工具状态和本地恢复记录。 |
八、模型差异被归一回 ChatResponse,再变成 AgentEvent
上一节讲了两套 wire shape,真正让上层保持稳定的是 ChatResponse。在 _reasoning_impl 里,
AgentScope 先发 ModelCallStartEvent,调用 _prepare_model_input 和 _call_model;
如果结果是 async generator,就逐个 chunk 转成事件;如果是完整 ChatResponse,也走同一套
_convert_chat_response_to_event。最后它发出 block end events、ModelCallEndEvent,
把 completed response 写入 context;如果没有 tool call,才返回最终 AssistantMsg。
这段在
_reasoning_impl。
换句话说,AgentScope 的 OpenAI Responses 支持不是“把 URL 换成 Responses 端点”。
它要保存 Responses 的 reasoning item id 和 function call call_id,要把 typed event 变成内部 content block,
还要保证下一轮 context 回放时 API 能接受。Chat Completions 支持也不是旧版 fallback,
它仍覆盖 OpenAI-compatible provider、audio-capable chat models 和传统 messages 生态。
九、service example 暴露的是服务平面
如果 AgentScope 只是一个本地脚本库,这条 reply 主线到这里就可以结束。但它的 example service 直接把
create_app、RedisStorage、InMemoryMessageBus、LocalWorkspaceManager、
默认 MCP、subagent template、CORS middleware 接起来。自定义 explorer 子 agent 还带了
PermissionMode.EXPLORE,也就是“只读 agent”不是 prompt 约定,而是权限上下文的一部分。
这段在
agent_service example。
这解释了为什么第一篇把 AgentScope 放在“事件化 agent runtime、权限、workspace、服务化 session”这一格。 它不是最轻的 API 封装,也不是先让你画 workflow graph;它更像一个把单 agent turn 做完整、再往 service 和 team 扩展的框架。
9.1 “恢复”要按层级说
| 中断层级 | 需要保存什么 | 本文能下的结论 |
|---|---|---|
| 同一 Agent 实例等待确认 | pending call、reply id、工具状态 | reply_stream 的恢复路径直接可见 |
| 浏览器重连 | 事件游标或 snapshot、reply 关联 | 需要 service adapter 明确实现,不能从 typed event 自动推出 |
| 进程重启 | 可重建 Agent 的完整 pending state | RedisStorage 的存在本身不足以证明 |
| Responses provider continuation | previous_response_id 等 provider identity | 只延续 provider response,不替代本地权限与工具状态 |
十、读完 AgentScope 先记住这些 invariant
| Invariant | 对应源码事实 | 工程含义 |
|---|---|---|
| 公开输出是事件,不只是文本。 | reply_stream 过滤最终 Msg,持续 yield AgentEvent。 |
前端、日志、控制台和服务端恢复都能跟踪中间态。 |
| 内部状态是结构化消息账本。 | Msg.content 是 content block list,context 由 agent 自己维护。 |
模型历史、工具结果、thinking 和 data 不会被压成同一段字符串。 |
| 工具调用先被治理,再被执行。 | _execute_tool_call 先 schema 校验和 permission check,再进入 _acting。 |
副作用边界能被审计、暂停、拒绝或交给外部系统。 |
| Responses 与 Chat 的差异在 adapter 层。 | 两个 model class、两个 formatter,最后都产出 ChatResponse。 |
上层 agent loop 不依赖某一个 provider 的 wire shape。 |
| 服务化不是后补 demo。 | example service 同时接 storage、message bus、workspace、MCP 和 subagent templates。 | AgentScope 的设计目标明显包含多人产品和长期 session。 |
| 运行结束不等于答案正确。 | ReplyEndEvent 封闭 reply lifecycle。 |
字段脱敏、业务规则与最终采用仍由校验器或调用方负责。 |
下一篇先问 Agent 的工作现场在哪里。AgentScope 已经把 workspace、storage、 session 与 service runtime 装到一起;下一篇会用同一项脚本任务分开 Coding / General 与 Local / Cloud, 再说明为什么 workspace、sandbox、artifact 与 memory 不能只当成一块磁盘。