跳到主要内容

agentscope — 本课题摘录

读了哪几篇: 01-reply-loop(回复状态机与事件流)、03-context-engineering(上下文工程三件套)。 其余四篇本轮没读。

这一家给了本课题一个结构性答案:循环体里不写任何"该不该结束"的判断。

它对本课题回答了什么

决定四(结构):把出口从循环体里搬出去

常见写法的问题它说得很准:出口和状态判断散落在循环体各处。 一旦要加"这个工具调用得等用户点同意",你就得在执行里往外抛信号、在循环里接、还要记住恢复时跳回哪一行。

它的形状是这样:

while 真:
动作 = 看一眼当前状态,算出该干什么 ← 只读,不改任何东西
照着动作做: 推理(调模型) / 行动(跑工具) / 收工(返回)

(依据:Agent 库 · AgentScope · reply 状态机与事件流 —— _next_action 是只读状态的决策函数,返回 Reasoning/Acting/Exit 三选一,循环体里没有任何判断「该不该结束」的逻辑)

这一条是本课题"循环怎么写"的最佳结构答案,值得直接抄: 循环体只负责执行动作,"下一步该干什么"是一个可以单独测试的纯函数。

好处不是好看,是可存盘: 状态在外面,那么任何时刻停下来、把状态存了、明天再算一次"下一步该干什么",循环就能接上。

它的决策顺序是一张表,三步走:

第一步 有没有能跑的工具调用?
├ 有 ──────────────────────► 行动
└ 没有,但有在等用户/等外部的 ─► 收工(但不发"真结束"的信号)= 挂起
第二步 这次要求结构化输出吗?
├ 要求了且已拿到 ──────────► 收工
├ 要求了还没拿到 ──────────► 推理(塞一条提醒)
└ 没要求 ↓
第三步 上一轮是不是产出了纯文本?
├ 是 ──────────────────────► 收工
├ 否且轮数到顶 ────────────► 收工(原因=超轮数)
└ 否 ──────────────────────► 推理

注意第三步的两个出口带着不同的原因——正常完成 vs 超轮数。这跟 fara 把超轮数混成"完成"正好相反,跟 kun 的"异常停要给原因"是同一条。

决定四最巧的一处:"收工"里带一个可空字段,空就表示"这不是真结束"

挂起的做法:把"在等什么"写进状态,然后正常结束这次函数调用,但不发结束事件。

为什么不发结束事件是关键:前端就是靠这个事件判断"这一轮真的结束了"。不发,前端就知道要留着这个会话等结果。 (依据:Agent 库 · AgentScope · reply 状态机与事件流 —— Exit.exit_events 可空,空表示「这不是真的结束,只是停下来」;挂起时不发 ReplyEndEvent,前端据此判断这一轮没结束、要留着会话等结果)

恢复路径更妙:用户点了确认,把结果塞回同一个方法——入参是个联合类型,普通消息和三种事件走同一个口子。

然后决策函数再跑一遍,看到有已放行的调用,返回"行动",循环无缝续上。

它自己的总结句值得原样记下:没有断点、没有续跑标记,全靠状态自然推导。

对比 letta-code 的"服务端存状态"、codex 的"回合队列"——agentscope 这一种最省: 续跑不是一个机制,而是"决策函数是纯函数"的自然结果。

决定三:工具调用不是两态,是五态

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

(依据:Agent 库 · AgentScope · reply 状态机与事件流 —— ToolCallState 五态是 PENDING/ASKING/ALLOWED/SUBMITTED/FINISHED,「成功、报错、被拒、被中断」都归入 FINISHED)

"被拒"和"被中断"也算完成——这条很重要:每个调用最终都必须有一个结果, 否则历史里就会留下一个"有调用没结果"的孤儿。 这是第五、第六家强调这条不变量了。

还有一条并发安全规则:只要还有任何一个调用在等用户回话,处于"待定"的调用就都不许动。 理由是避免"用户还在看第一个确认框,第二个工具已经把文件改了"。

一条容易踩的顺序:先改状态,再发事件

源码里两处都专门加了注释强调这个顺序。 原因很实在:发事件之后外层循环会立刻跳出,发事件后面的代码根本执行不到。

跟 kun 的"先落盘再发布"是同一类顺序陷阱:发事件这个动作可能是不返回的。 凡是"发出去之后可能就不回来了"的调用,它后面不能有必须执行的代码。

决定一:变的东西追加在末尾,不改前缀

模型不知道现在几点、不知道自己还有几个未完成任务、不知道上下文快满了——这些每轮都在变。

它不写进系统提示,因为会毁掉缓存;而是做成一种特殊内容块,格式化时转成一条消息追加在末尾。前缀不动,缓存命中。 (依据:Agent 库 · AgentScope · 上下文工程三件套 —— 时间/任务/上下文用量用 HintBlock 注入而非改写 system prompt,HintBlock 格式化时转成一条 user 消息追加在末尾,以保住 prompt 缓存)

这是本课题第三次撞上同一条规律(kun 的"实时用量刻意不注入"、nanobot 的"时间放末尾)。 可以写成一句定则:凡是每轮都变的内容,只能追加在末尾,不能改前缀。

注入的文案里有一句必须抄:"把下面这段当作此刻的事实。更早说过的都过期了,后面若还有提醒则以后面的为准。"

理由:注入是持久写进历史的,所以历史里会有多条时间戳,必须告诉模型以最新的为准。

还有一个工程细节:模板带校验器,模板里少了占位符直接报错——免得注入被静默丢掉。

决定一:压缩时工具调用配对不能拆散

切压缩边界时要反复推移直到配对稳定:

检查保留区里有没有「有结果但没有对应调用」的孤儿
├ 没有 → 切点稳定,收工
└ 有 → 把最靠后的孤儿也推进压缩区,回到检查

为什么要迭代而不是一次修正:推移边界本身会制造新的孤儿。 (依据:Agent 库 · AgentScope · 上下文工程三件套 —— 压缩切点要 while True 反复推移直到工具调用配对稳定,因为推移边界本身会把另一个 tool call 带进压缩区而留下它的 result)

压缩自己也可能撑爆——降级路径是从最旧的消息开始逐条丢,边丢边重新估算。

有一个错误它明确不容忍:上下文为空但仍超阈值,说明系统提示本身就超了,直接报错给开发者。

"哪些错误要兜、哪些错误必须炸"这个判断很值钱。 系统提示超窗是配置错误,兜住只会让它更晚被发现。

一条数 token 的取巧

它不真的分词,把文本拼起来按字节数除以 4;多模态块按固定值估。

对中文,字节除以 4 会显著高估(一个汉字三字节约合 0.75,实际常在 0.6 左右)——它是保守的,宁可早压缩不要撑爆。

对比 goose 的"用厂商回报的真实用量": goose 那条更准。 但 agentscope 这条在"还没发出去就要先估"的场合是必需的——发之前没有真实用量可用。 两条不冲突:发之前用估算决定压不压,发之后用真实值累加。

它没回答什么

  • 怎么认出模型要调工具——它假设原生工具调用。
  • 并发——这两篇没讲一批工具怎么并行。

坑与代价

  • "决策函数只读"要求所有状态都写进那条消息里。 任何一处偷偷用局部变量记状态,存盘续跑就会断。

    判断(无锚): 这个约束对最小原型是好事——它逼你把状态显式化。 如果错,会错在: 如果我们的第一版根本不需要中断续跑(一次跑完就结束),那把状态全塞进消息是额外负担,用局部变量更直白。

  • 五态状态机需要每个状态转换都有人维护。 漏一个转换就会卡住。
  • 注入是持久写进历史的,于是历史里会堆多条过期的时间戳——靠一句"以最新为准"来兜,不算干净。