3. 从多 Agent 到单环与写边界#
一句话结论:按读写切的子 Agent 我做过两代,最后按线上数据自己删了;写边界的三样结构性保证原样留下。
速背卡#
| # | hint(≤15 字) | 展开一句 |
|---|---|---|
| 1 | 440 / 60 / 0 | TradeAgent 440 会话 0 派发、SearchAgent 60 次派发全是单跳壳 |
| 2 | 删载体不删原则 | 派发机制整条删,写边界三样保证一样不少 |
| 3 | 三样保证 | is_read_only 标记 + 按名 ALLOW 不用 BYPASS + 决议只走 HTTP |
| 4 | 并行 = 同轮 batch | 跨平台/多槽位同轮多发 item_search,框架 gather |
| 5 | 幂等键 = run_id + 顺序无关指纹 | request_key(run_id, action, 排过序的载荷 hash) |
| 6 | 订单号撞了重分不覆盖 | save 抛 DuplicateRequestError,重新分号重试 |
| 7 | 并发要按批不按个 | 终结硬停闸的布尔位升级成 terminal_step |
30 秒版#
这个项目的多 Agent 做过两代:同质 fork、然后 Supervisor-Workers 按读写切(SearchAgent 只读、TradeAgent 只写)。跑起来翻真实会话:TradeAgent 440 会话 0 次派发,SearchAgent 60 次派发全是单跳壳。我把整条派发机制删了,并行改成同轮发多个工具调用。写边界不靠派发撑着,靠 is_read_only 标记、PermissionEngine 按工具名精准放行、以及下单决议只走 HTTP 这三样,单环下照样成立。
3 分钟版#
- 第一代同质 fork(M2 / M9):子 Agent 是主 Agent 的完整克隆,主 prompt 里的收尾 / 套装段对它全是噪声。
- 第二代按读写切(M25):SearchAgent 只拿
item_search/web_search,TradeAgent 只拿三个订单工具——边界是「Toolkit 里根本没有那个工具对象」,不是提示词劝。 - 量了之后删掉(2026-09-16,A1
2242ef5/ A4-1cccdf1a):派发的三条判据(能并行 / 要隔离 / 链够深)一条都没兑现,见下面的数字表。 - 并行换成同轮 batch(A3
ff023f4):跨平台与套装槽位同轮多发item_search(platform=… / slot=…),框架按is_concurrency_safe=True合批并发。 - 写边界留下:
permissions.py在 A4-1 里明确保留——AgentScopePermissionMode.DEFAULT会挂起所有非只读工具,写工具仍要按名 allow。 - 删完踩到并发坑(
94f6911):终结硬停闸用一个布尔位记「收过尾了」,同轮双create_order并发交错,只落一张卡、文案却说两张。 - 写边界后来又收口(阶段 3
bfa99c3,2026-09-21):确认卡按run_id幂等,订单号并发撞号不再互相覆盖。
它解决什么问题#
① 为并行而派发,收益假设错了
问题:跨平台检索想并行。坏法:为它引一层子 Agent——不修的坏处不是报错,是每次派发白付一次 Agent 装配 + 一份独立上下文 + 一次结果回传,而买到的只有一次工具调用,且这笔浪费在日志里看不出来(派发成功、结果也对)。修法:删派发,同轮多发工具调用,调度交框架。代价:跨平台的「彼此看不见对方已找到什么」这种隔离没了——实测也没人用上(1396ed1 一并删了 isolated_retrieval_scope)。
② 写工具的边界不能写在提示词里
问题:模型分不稳「用户说想买」和「用户确认要买」。坏法:prompt 里写「未经确认不要下单」——不修的坏处是偶发真落一张单,而且是低频、难复现、用户才发现。修法:create_order / cancel_order 只写一张 pending 确认卡,真正落单的 resolve 不注册为工具,只有 POST /api/threads/{tid}/confirmations/{cid}/resolve(app/api/orders.py:235)。代价:对话里说「确认」不算数,用户必须点按钮;多一张表和一套前端卡。
③ 框架默认对非只读工具弹确认,会把主链路停在半路
问题:ask_user / save_memory / 终结工具都是工作流零件,每次弹确认就卡死。坏法:PermissionMode.BYPASS 整档关——不修的坏处是将来新加的真写工具被一起悄悄放行,而且没人会注意到。修法:DEFAULT_ALLOWED_TOOLS 按工具名逐个 ALLOW(app/agent/permissions.py:30),每项注释写清「为什么它不需要弹确认」。代价:新写工具忘了登记就会挂起,只留一条 warning;失效方向是「多问一次」不是「悄悄下单」。
④ 漏登记这件事靠人记不住
问题:present_comparison 漏在放行表外很久没被发现——它还有一条 REST 入口(对比栏按钮不走 AgentLoop),把「模型调它会被挂起」这条路遮住了。修法:S3(3943ecf)补 present_guide 时一并补登,并加 tests/test_orchestrator.py:test_every_write_tool_is_allowlisted——断言「每个非只读工具都在放行集里」。代价:无,这条测试是纯增量守栏。
⑤ 出卡这侧没有请求级幂等键
问题:整轮重跑(PEL 重投 / worker 重领)会出第二张确认卡,用户不知道点哪张。修法:trade_confirmations.request_key 唯一约束,键 = run_id:action:载荷指纹(app/trade/confirmation.py:57)。指纹按 item_id 排过序、不复用 snapshot_hash——后者要和 payload 严格对应、行顺序随模型入参走,重跑换个顺序就失效。代价:run_id 为空(HTTP 表单入口 / 离线脚本)写 NULL 不参与约束,那条路仍是老行为。
⑥ 订单号按行数编号,并发会算出同一个号
问题:next_order_id 按行数编号,两张卡同时 approve 算出同号,而 save 是 upsert——后到那路把先到的订单原地改写成自己的,库里还是一张单,先下单的人那张成了别人的。修法:save 撞号即抛 DuplicateRequestError,_place_from_snapshot 重新分号重试(app/trade/confirmations.py:348)。代价:仓储层要把 IntegrityError 翻成领域异常,用例层才不用 import SQLAlchemy。
机制怎么跑#
装配:一个 Agent,一份 Toolkit#
app/agent/agents.py:build_main_agent→_assemble():全仓只有这一个 Agent 构造口,没有 worker 构造口。build_toolkit(tool_middlewares=[HarnessToolAdapter(session)])(app/agent/tool_registry.py:119):19 个业务工具全给它,另挂 Skill loaders 与 MCP 白名单。_make_tools()按is_read_only=t.name in _READ_ONLY_TOOLS逐个标只读;11 个只读(planner/item_search/query_order/recall_memories等),其余是非只读。allow_tools(agent_state)(app/agent/permissions.py:51):给 8 个非只读工具加工具名级 ALLOW 规则,rule_content=None、source="projectSettings",幂等去重(同一 state 跨轮复用,不去重规则表会线性膨胀)。- 边界口径是「这份 Toolkit 里有没有」,不用
ToolGroup:组是运行时可激活 / 停用的「按需露出」,模型能调 meta tool 把组激活回来,不是权限(tool_registry.py:74那段注释)。
并行:同轮 batch#
- 主环同一轮发出 N 条
item_search(platform=… / slot=…),框架按is_concurrency_safe=True合成一个 concurrent 批asyncio.gather跑。 - 槽位只活一轮、槽名即身份(A4-2
d6be5de):BundleSlot.id/bundle.json/slot_scope/detect_slot全删,_bundle.py947 行 → 764 行。
写路径:出卡 → HTTP 决议 → 落单#
- 模型调
create_order(item_ids, 收件信息)→prepare_order_confirmation:商品按item_id从会话候选登记表 hydrate,入参不收标题与价格(模型重吐会把要落库的钱记错)。 _save_pending(app/trade/confirmations.py:173):算request_key,先查一次挡顺序重跑,唯一约束挡并发(两路同时查空同时插,后插那路撞键、重读拿先到的卡)。卡expires_at = now + CONFIRMATION_TTL(30 分钟,app/trade/confirmation.py:22)。- 用户点按钮 →
POST /api/threads/{tid}/confirmations/{cid}/resolve:校验登录与会话归属、过期、snapshot_hash比对;已决议的卡同决定原样返回、异决定 conflict。 - approved + create →
_place_from_snapshot:按卡上快照落单(不回候选池 hydrate,刷新页面后池可能已空),幂等键 =operation_id,靠orders.idempotency_key唯一约束挡同时点两下。 - 下一轮装配时
hooks/context_shaping.render_trade_state_block把未过期 pending 卡 + 本会话订单渲染成<trade_state>注回去——交易状态以服务端为准,不让模型凭对话记忆猜。 cancel_order前必须先query_order:hooks/sequencing.check_trade_sequence(pre_tool_call 12)本轮轨迹里没有query_order就硬拒,拦的是模型编订单号。HTTP 的POST /api/orders/{id}/cancel不走这道闸,因为号来自页面刚渲染的卡片。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| M2 / M9 | — | 同质 fork:子 Agent = 主 Agent 完整克隆 | 最早照教学范式做(旧版核对过) |
| M25 | — | 改 Supervisor-Workers,按读写切 search / trade | 边界要结构性,worker 手里没有写工具对象 |
| 2026-09-14 | 932b481 → efb8c2f | 两段式 confirmed=True 改为确认卡落表、决议只走 HTTP | 会话级 guard 拦不住重启与刷新(旧版核对过) |
| 2026-09-16 | 2242ef5(A1) | 删 TradeAgent,交易工具只留主 Agent | 440 会话 trade 派发 0 次 |
| 2026-09-16 | 1396ed1(A2) | 删定点调查及隔离检索、target_refs | 481 会话真实用户拆出 target_refs 0 次 |
| 2026-09-16 | ff023f4(A3) | 套装 / 跨平台改主环同轮 batch item_search | 并行不需要派发 |
| 2026-09-16 | cccdf1a(A4-1) | 删 dispatch_tool.py / fork_guard.py / SearchAgent / fork 预算闸;MCP 改挂 main;permissions.py 保留 | 60 次派发全是单跳壳 |
| 2026-09-16 | d6be5de(A4-2) | 槽只活一轮、槽名即身份 | 槽 id 是派发时代的产物 |
| 2026-09-16 | 71d9acb(A5) | 前端删 fork 渲染;确认卡标「N 张待确认」 | 同轮 batch 会落多张卡,点完第一张别以为完事 |
| 2026-09-16 | f8b3178 / ec46140(A6) | monitor token 去「全树」口径;pyproject / context.py 措辞 | 入库文件里「子 Agent fork」字样归零 |
| 2026-09-16 | 94f6911 | 终结硬停闸加 terminal_step,同批兄弟调用放行 | 同轮双 create_order 只落一张卡 |
| 2026-09-19 | 3943ecf(S3) | 加 present_guide,补两个写工具放行 + 全量守栏测试 | present_comparison 漏登记被 REST 入口遮住 |
| 2026-09-21 | bfa99c3(阶段 3) | 确认卡 request_key 幂等;订单号撞了重分不覆盖 | 重投出第二张卡;并发同号互相改写 |
| 2026-09-21 | 408c99d(减法 C4) | build_toolkit() / skill_loaders() / mcp_clients() 删 role 参数,三张单元素表删除 | 单环之后 role 只剩 main 一个值 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 440 会话 0 次 | TradeAgent 真实派发次数 | 2242ef5 正文 | ✓ |
| 60 次全是单跳壳 | SearchAgent 派发的链深(派出只调 1 次 item_search 就回) | A4 删除决策依据,旧版核对过 | ✓ |
| 481 会话 0 次 | 真实用户拆出 target_refs 的次数(定点调查) | 1396ed1 正文 | ✓ |
q21 3 条 / q22 4~6 条,0 次 task_dispatch | 删派发后套装轮同轮 batch 条数,真 LLM 4 遍 | scripts/eval/snapshot/test_bundle_batch.py | ✓ |
| 0 / 0 | clone / split 两组 worker 的写工具误调 | data/eval/worker_mode_*.json 的 worker_write_calls | ✓ |
| split 1.5 / clone 4.17 | worker 平均工具调用次数(每条 query) | 同上 worker_tool_calls | ✓ |
| 301 会话 0 次 | 深度闸 / 工具白名单触发次数(因此降成只报警) | 8eaf826 正文,旧版核对过 | ✓ |
| 30 分钟 | 确认卡有效期 | app/trade/confirmation.py:22 CONFIRMATION_TTL | ✓ |
| 19 / 11 / 8 | 业务工具数 / 只读 / 非只读(= 放行表条目) | app/agent/tool_registry.py:48,74、permissions.py:30 | ✓ |
| 1419 passed | 阶段 3 写边界收口后的全量测试 | bfa99c3 正文 | ✓ |
| 947 → 764 行 | _bundle.py 删槽 id 后的行数 | d6be5de 正文 | ✓ |
追问 10 题#
Q1. 你做了两代多 Agent,为什么全删了?
判据没兑现:TradeAgent 440 次机会 0 次派发,SearchAgent 60 次派发全是单跳壳——为「可能的并行」付了装配 + 独立上下文 + 回传的固定成本,只买到一次工具调用。证据:2242ef5 / cccdf1a 正文。
Q2. ⚠ 那当初为什么不直接上单环?
当初没有这些数字。读写切分在写安全上是成立的(worker 的 Toolkit 里根本没有写工具对象),错的是「派发能换来并行收益」这个假设,而收益只有跑起来才量得到。证据:docs/decisions/0002-派发从批量改单条的能力回退.md。
Q3. 删的时候怎么保证没削掉能力?
先立尺子再动刀:A0 阶段先写快照评测(构造 session.json 起跑、断言工具序列与最终状态),每步删完跑全量 pytest + 真 LLM 快照对照。套装的验收断言就是「同一轮发出 ≥2 条 item_search」。证据:scripts/eval/snapshot/。
Q4. 单环之后写边界靠什么?
三样:① is_read_only 标记(tool_registry._READ_ONLY_TOOLS);② PermissionEngine 按工具名 ALLOW、不用 BYPASS;③ 交易决议只走 HTTP,模型手里没有 resolve。
Q5. ⚠ 为什么不用 PermissionMode.BYPASS?
BYPASS 整档关掉引擎,将来新加的真写工具会被一起悄悄放行。按名 ALLOW 让放行范围写死在代码里看得见,新写工具默认被拦。证据:app/agent/permissions.py 模块 docstring。
Q6. 写工具漏进放行表会怎样?
框架发 RequireUserConfirmEvent 挂起 reply,前端没有接这套协议的通路,本轮拿不到最终消息,日志只有一条 warning。现在由 tests/test_orchestrator.py:test_every_write_tool_is_allowlisted 守着——present_comparison 就是这么漏掉的(S3 3943ecf 补上)。
Q7. 同一张确认卡被点两次会不会下两单?
不会。先后两次点击靠 find_by_idempotency_key(键 = operation_id);同时点两下两路都查空,靠 orders.idempotency_key 的唯一约束判先到,撞了重读返回同一个 order_id。证据:app/trade/confirmations.py:348。
Q8. ⚠ 整轮被重投,会不会出两张确认卡?
不会(阶段 3 起)。幂等键是 run_id:action:载荷指纹,run_id 经 thread_scope 进 ContextVar、重投不变。计划里写的 tool_call_id 用不了——AgentScope 的 ToolMiddlewareBase 不传 tool_call 的 id(adapter.py 里 ctx["tool_call_id"] = "" 就是证据)。
Q9. 为什么幂等指纹不复用 snapshot_hash?
snapshot_hash 要和 payload 严格一一对应(前端拿它比对「点的是不是看到的那张」),行顺序随模型入参走;重跑时模型把同样两件商品换个顺序报上来,严格 hash 就变了、幂等失效。所以指纹用 _order_insensitive(按 item_id 排序)的那份,落库的仍是原 payload。
Q10. ⚠ 压力题:删掉派发之后,还有什么 per-turn 状态是错的?
终结硬停闸。它用布尔位记「本轮收过尾了」,而同轮两个 create_order 是 gather 并发跑的,谁先跑完谁把兄弟拦死——真跑只落一张卡、文案却说两张,第二件静默消失。修法是记下置位时的 think_step(同一条 AI 消息的并行调用共享它,等于批次 id),同批且当前也是终结工具才放行。教训:并行从派发换成 batch 后,所有 per-turn 的布尔闸都要按「批」重判(94f6911)。
坑与易混点#
- MCP 工具不经过 harness 工具中间件(框架在
Toolkit内部造对象,够不着):没有截断、内容过滤、白名单闸,也不发tool_start/tool_end。所以只接只读无副作用的 server(app/agent/mcp_registry.py:23)。 - 环境变量名仍叫
MCP_SEARCH_*,是 SearchAgent 时代的遗留,不是角色名——现在 MCP 只挂主 Agent。 - 「订单不存在」与「不属于你」故意回同一个 404(
usecases._load_owned):区分两者等于给出订单号探测器。 create_order/cancel_order是终结工具,调完本轮就结束——模型不能在同一轮里先出卡再接着聊。scripts/archive/现在只剩verify_parallel.py;compare_worker_mode.py已不在树内,worker 对照数据只剩data/eval/worker_mode_*.json(与旧版手册「对照脚本在 scripts/archive/」的说法不符)。
本章和别章的接口#
- 终结工具集合与终止安全(
TERMINAL_TOOLS、硬停闸全貌)在第 2 章。 - harness 六个 hook 点与
sequencing/termination的注册顺序在第 6 章。 - 队列、重投、
run_id从哪来(worker 与多副本)在第 10 章。