跳到主要内容

数据截至 (上游 commit 3a4e2ae3eec0)

第 1 章 · reply 状态机与事件流

这一章讲:一次 agent.reply() 内部到底发生了什么,以及它凭什么能停在半路又接着跑。


1.1 先看形状:循环写成了什么样

大多数 agent 框架的 ReAct 循环长这样(伪代码):

# 示意,非源码:常见写法
while step < max_steps:
response = call_model(memory) # 推理
if not response.tool_calls:
return response.text # 出口埋在循环体里
for call in response.tool_calls:
memory.append(run_tool(call)) # 行动

问题在于出口和状态判断散落在循环体各处。一旦要加「这个工具调用得等用户点同意」,你就得在 run_tool 里往外抛信号、在 while 里接、还要记住恢复时跳回哪一行。

AgentScope 把它翻了个面:

# 示意,非源码:AgentScope 的形状
while True:
action = self._next_action(final_msg) # 只读状态,返回三选一
match action:
case Reasoning(...): ... # 调模型
case Acting(...): ... # 跑工具
case Exit(...): return # 收尾

重点看:循环体里没有任何判断「该不该结束」的逻辑,那些全在 _next_action 里。真实实现在 _reply_impl 方法(src/agentscope/agent/_agent.py:915)的 match next_action: 段(src/agentscope/agent/_agent.py:1028-1161),三个动作类型定义在 src/agentscope/agent/_utils.py:26-43

class Acting(BaseModel):
tool_calls: list[ToolCallBlock]

class Reasoning(BaseModel):
hint: HintBlock | None = None
tool_choice: ToolChoice | None = None

class Exit(BaseModel):
exit_msg: Msg
exit_events: list[AgentEvent] | None = None

注意 Exit.exit_events可空的——空表示「这不是真的结束,只是停下来」。这一个可空字段撑起了整个人工确认机制,下面 1.4 详述。


1.2 _next_action:一张决策表

_next_actionsrc/agentscope/agent/_agent.py:3248)按固定顺序问三组问题。理解它就理解了整个循环。

决策顺序

Step 1 有没有能跑的工具调用?
├─ 有 ────────────────────────► Acting
└─ 没有,但有在等用户/等外部的 ─► Exit(exit_events=None,即挂起)


Step 2 这次 reply 要求结构化输出吗?
├─ 要求了,且已拿到 ──────────► Exit(COMPLETED)
├─ 要求了,还没拿到 ──────────► Reasoning(塞一条提醒 hint)
└─ 没要求 ─┐

Step 3 上一轮推理是不是产出了纯文本?
├─ 是 ────────────────────────► Exit(COMPLETED)
├─ 否,且 cur_iter ≥ max_iters ► Exit(EXCEED_MAX_ITERS)
└─ 否 ────────────────────────► Reasoning

Step 1 的细节:为什么要分「可执行」和「在等」

源码里这段过滤是核心(src/agentscope/agent/_agent.py:3268-3279):

executable_tool_calls = [
_
for _ in last_msg.get_content_blocks("tool_call")
if _.id not in finished_ids
and (
_.state == ToolCallState.ALLOWED
or (
_.state == ToolCallState.PENDING
and not awaiting_tool_calls
)
)
]

翻译:只要还有任何一个调用在等用户回话,处于 PENDING 的调用就都不许动。这是为了避免「用户还在看第一个确认框,第二个工具已经把文件改了」。已经被用户放行(ALLOWED)的可以继续跑。

结构化输出的宽限期

要求结构化输出但一直没生成时,循环不会在 max_iters 处硬停,而是再给 structured_output_grace_iters(默认 5,见 src/agentscope/agent/_config.py:308-315)轮。并且一旦 cur_iter 触到 max_iterstool_choice 会被强制设成 _GenerateStructuredOutputsrc/agentscope/agent/_agent.py:3370-3375)——不再给模型选择的余地,必须现在就产出结果

这个「软上限 + 硬逼」的两段式,比单纯截断要体面得多。


1.3 工具调用的状态机

一次工具调用不是「跑/没跑」两态,而是五态(src/agentscope/message/_block.py:128-135):

状态含义
PENDING刚从模型解析出来,还没过权限
ASKING权限判定要求用户确认,正在等
ALLOWED已放行,等待或正在执行
SUBMITTED是外部工具,已交给外部系统,等回结果
FINISHED有结果了(成功、报错、被拒、被中断都算)

流转图(ToolCallBlock 的 docstring 里有等价版本):

PENDING
├── 权限 DENY / 入参校验失败 ──────► FINISHED
├── 权限 ASK ──► ASKING ──┬─ 用户拒 ─► FINISHED
│ └─ 用户准 ─► ALLOWED
└── 权限 ALLOW ───────────────────► ALLOWED

ALLOWED
├── 本地工具,跑完 ───────────────► FINISHED
└── 外部工具 ─────────► SUBMITTED ─► FINISHED(收到外部结果)

状态先改再发事件——源码里两处都专门加了注释强调这个顺序:转 ASKINGsrc/agentscope/agent/_agent.py:2373-2378(注释 "the update must be done before yielding the event"),转 SUBMITTED:2264-2272(注释 "Update the state to 'submitted' BEFORE yielding")。原因很实在:yield 之后外层循环会立刻 breakyield 后面的代码根本执行不到。


1.4 挂起:一次 reply 怎么「停在半路」

问题

模型说要 rm -rf build/。你想让用户先确认。用户可能三十秒后点,也可能明天点。这中间不能占着一个协程干等。

思路

把「在等什么」写进状态,然后正常结束这次函数调用,但不发结束事件

挂起路径

_execute_tool_call 判定 ASK
│ 1. 把调用状态改成 ASKING(写进 state.context 的那条消息里)
│ 2. yield RequireUserConfirmEvent(带上「建议规则」)

_reply_impl 收到该事件 → break_execution_for_hitl = True → 跳出批次循环


_next_action 看到 awaiting_tool_calls 非空


Exit(exit_events=None, exit_msg="I'm waiting for your permission ...")


_reply_impl: 「if not exit_events: yield exit_msg; return」——不发 ReplyEndEvent

对应源码:src/agentscope/agent/_agent.py:3284-3297(生成挂起用的 Exit)和 :888-892(不发结束事件就返回)。

为什么不发 ReplyEndEvent 是关键:前端/服务层就是靠这个事件判断「这一轮真的结束了」。不发,前端就知道要留着这个会话等结果。源码注释直说:"Parked on HITL: the reply is not finished, so the continuation protocol doesn't apply"

恢复路径

用户点了确认,调用方把结果塞回来:

# 示意,非源码
evt = UserConfirmResultEvent(reply_id=..., confirm_results=[...])
final_msg = await agent.reply(evt) # 同一个方法,输入换成事件

reply() 的入参是个联合类型,Msg 和三种事件走同一个口子(src/agentscope/agent/_agent.py:300-308)。_handle_incoming_event:1591)负责把确认结果落到状态上:

  • 用户同意 → 状态改 ALLOWED:1635-1638),并且允许用户修改工具名和入参:1642-1643),改完的值直接生效。
  • 用户同意时附带了规则 → 通过 self._engine.add_rule(rule) 加进权限引擎(:1646-1648),下次同类调用不再问。
  • 用户拒绝 → 走 _handle_error_tool_call 写一条 DENIED 结果(:1653-1663)。

然后 _next_action 再跑一遍,看到有 ALLOWED 的调用,返回 Acting,循环无缝续上。没有断点、没有续跑标记,全靠状态自然推导。

三种恢复输入

输入类型场景效果
UserConfirmResultEvent用户点了同意/拒绝更新调用状态,继续循环
ExternalExecutionResultEvent外部系统(如前端浏览器)执行完了直接把结果写进上下文
UserInterruptEvent用户放弃这次挂起给悬空调用补 INTERRUPTED 结果,结束

1.5 中断:Ctrl+C 之后上下文不能是坏的

问题

模型发了三个工具调用,第一个跑到一半用户按了 Ctrl+C。这时上下文里有三个 tool_call 块,零个 tool_result 块。下次把这段历史喂给模型,OpenAI 直接报 400——tool_call 必须配对 tool_result。

解法

_close_unfinished_tool_callssrc/agentscope/agent/_agent.py:850)在中断收尾时扫最后一条消息,给每个没有结果的调用补一条(:732-735):

interruption_message = (
"<system-reminder>The tool call has been interrupted by "
"the user.</system-reminder>"
)

并把状态置为 FINISHED、结果状态置为 INTERRUPTED。上下文重新变成合法的。

中断在哪被捕获

_reply_impltry / except asyncio.CancelledError / finally 三段式(try:786exceptfinally:1021-1053):

try: 正常循环
except CancelledError:
end_event = ReplyEndEvent(INTERRUPTED)
若 interruption_raise_cancelled_error 为 True 则再抛出
finally: 若 end_event 存在:
补齐悬空工具结果 → 发 end_event → 发一条兜底 AssistantMsg

注意最后那条兜底消息(内容默认 "I notice the interruption. How can I help you?",见 src/agentscope/agent/_config.py:330-334必须放在最后——源码注释写着 "The fallback msg goes last: Msg terminates the stream"。因为消费方(如 reply())是拿最后一个 Msg 当返回值的。

并发批次里的中断更绕

_execute_concurrent_tool_calls:1844)被取消时做了三件事(:1944-1965):

  1. gather_task.cancel() 取消所有工人任务(:1946-1950);
  2. 把队列里已经排好队的事件全部 flush 给调用方(:1951-1958,包括工具自己产生的 INTERRUPTED 块);
  3. asyncio.current_task().uncancel():1964)—— 吞掉取消信号,让生成器正常返回

第 3 步是刻意的:调用方靠 flush 出来的 ToolResultEndEvent(state=INTERRUPTED) 事件来判断中断,而不是靠异常。这样并发路径和串行路径的语义就统一了——源码注释明写它「镜像 _execute_sequential_tool_calls 的事件式传播」。


1.6 事件流:给前端看的那一层

事件家族

EventTypesrc/agentscope/event/_event.py:26)共 28 个成员,块类事件按「START / DELTA / END 三段式」组织:

家族成员说明
回复级REPLY_START / REPLY_END一次 reply 的边界
模型级MODEL_CALL_START / MODEL_CALL_END单次模型调用,END 带 token 用量
内容块级TEXT_/THINKING_/DATA_BLOCK_START/DELTA/END流式增量
单发块HINT_BLOCK运行时状态注入(见第 3 章),没有增量
工具级TOOL_CALL_START/DELTA/ENDTOOL_RESULT_START/TEXT_DELTA/DATA_DELTA/END工具调用的参数流 + 结果流
交互级REQUIRE_USER_CONFIRMUSER_CONFIRM_RESULTUSER_INTERRUPTREQUIRE_EXTERNAL_EXECUTIONEXTERNAL_EXECUTION_RESULT人机交互
其它EXCEED_MAX_ITERSCUSTOM超轮上限通知;开发者自定义事件

事件能重放成消息

最巧的一处:Msg.append_eventsrc/agentscope/message/_base.py:244)能把事件流逆向拼回一条完整的 Msg。服务端就是靠它,在 SSE 推流的同时重建要落库的回复。

里面有个易踩的坑被处理掉了(:324-336):多模态的 base64 增量不能直接字符串拼接,因为每段各带自己的 padding。源码是解码成字节、拼接、再编码:

existing = (
base64.b64decode(block.source.data)
if block.source.data
else b""
)
incoming = base64.b64decode(event.data)
block.source.data = base64.b64encode(
existing + incoming,
).decode("ascii")

同样的逻辑在工具结果侧也复刻了一份(src/agentscope/tool/_response.py:13_merge_base64_chunks)。

中间件可以「吞掉」结束事件

_reply 外层(src/agentscope/agent/_agent.py:780)有个反直觉但很强的设计(:700-706):

self._receive_reply_end = False
async for item in agen:
# Set before the yield: the suspended `_reply_impl` checks the
# flag once resumed by the next pull
if isinstance(item, ReplyEndEvent):
self._receive_reply_end = True
yield item

只有当 ReplyEndEvent 穿透整条中间件链跑出来_reply_impl 才真的退出。中间件如果收到它但不往外 yield(「吞掉」),循环就会再转一圈。这让「跑完了但我觉得还不够,再来一轮」变成一行中间件逻辑。

配套的防呆:连续两次吞掉且中间没有任何推理/行动,直接抛 RuntimeError:904-911),提示开发者先调 cur_iter / max_iters 再吞。


1.7 一轮的边界在哪

cur_iter 什么时候 +1?源码里只有一处(src/agentscope/agent/_agent.py:1160-1161):

if not self.state.get_unfinished_tool_calls(self.name):
self.state.cur_iter += 1

即:只有当这次 reply 产生的全部工具调用都有了结果,才算走完一轮。 推理刚产出工具调用时不算,行动被人工确认卡住时也不算。这个定义让 max_iters 度量的是「完整的思考-行动回合数」,而不是「函数被调了几次」。


1.8 代码地图

主题文件路径符号名
循环执行器src/agentscope/agent/_agent.py_reply_impl
决策纯函数src/agentscope/agent/_agent.py_next_action
三种动作类型src/agentscope/agent/_utils.pyReasoningActingExit
推理与流式转事件src/agentscope/agent/_agent.py_reasoning_impl_convert_chat_response_to_event
单个工具调用全生命周期src/agentscope/agent/_agent.py_execute_tool_call
并发批次与中断 flushsrc/agentscope/agent/_agent.py_execute_concurrent_tool_calls_into_queue
中断补齐src/agentscope/agent/_agent.py_close_unfinished_tool_calls
HITL 恢复src/agentscope/agent/_agent.py_handle_incoming_event_check_incoming_event
工具调用状态枚举src/agentscope/message/_block.pyToolCallStateToolCallBlock
悬空调用查询src/agentscope/state/_state.pyget_awaiting_tool_callsget_unfinished_tool_calls
事件定义src/agentscope/event/_event.pyEventTypeReplyEndEvent
事件重放成消息src/agentscope/message/_base.pyMsg.append_event
结构化输出工具src/agentscope/agent/_structured_output_tool.py_GenerateStructuredOutput
相关测试tests/agent_basic_test.pyagent_interrupt_test.pyhitl_user_confirmation_test.py