面试知识库

3. 从多 Agent 到单环与写边界#

一句话结论:按读写切的子 Agent 我做过两代,最后按线上数据自己删了;写边界的三样结构性保证原样留下。

速背卡#

#hint(≤15 字)展开一句
1440 / 60 / 0TradeAgent 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 分钟版#

  1. 第一代同质 fork(M2 / M9):子 Agent 是主 Agent 的完整克隆,主 prompt 里的收尾 / 套装段对它全是噪声。
  2. 第二代按读写切(M25):SearchAgent 只拿 item_search / web_search,TradeAgent 只拿三个订单工具——边界是「Toolkit 里根本没有那个工具对象」,不是提示词劝。
  3. 量了之后删掉(2026-09-16,A1 2242ef5 / A4-1 cccdf1a):派发的三条判据(能并行 / 要隔离 / 链够深)一条都没兑现,见下面的数字表。
  4. 并行换成同轮 batch(A3 ff023f4):跨平台与套装槽位同轮多发 item_search(platform=… / slot=…),框架按 is_concurrency_safe=True 合批并发。
  5. 写边界留下:permissions.py 在 A4-1 里明确保留——AgentScope PermissionMode.DEFAULT 会挂起所有非只读工具,写工具仍要按名 allow。
  6. 删完踩到并发坑(94f6911):终结硬停闸用一个布尔位记「收过尾了」,同轮双 create_order 并发交错,只落一张卡、文案却说两张。
  7. 写边界后来又收口(阶段 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#

  1. app/agent/agents.py:build_main_agent → _assemble():全仓只有这一个 Agent 构造口,没有 worker 构造口。
  2. build_toolkit(tool_middlewares=[HarnessToolAdapter(session)])(app/agent/tool_registry.py:119):19 个业务工具全给它,另挂 Skill loaders 与 MCP 白名单。
  3. _make_tools() 按 is_read_only=t.name in _READ_ONLY_TOOLS 逐个标只读;11 个只读(planner / item_search / query_order / recall_memories 等),其余是非只读。
  4. allow_tools(agent_state)(app/agent/permissions.py:51):给 8 个非只读工具加工具名级 ALLOW 规则,rule_content=None、source="projectSettings",幂等去重(同一 state 跨轮复用,不去重规则表会线性膨胀)。
  5. 边界口径是「这份 Toolkit 里有没有」,不用 ToolGroup:组是运行时可激活 / 停用的「按需露出」,模型能调 meta tool 把组激活回来,不是权限(tool_registry.py:74 那段注释)。

并行:同轮 batch#

  1. 主环同一轮发出 N 条 item_search(platform=… / slot=…),框架按 is_concurrency_safe=True 合成一个 concurrent 批 asyncio.gather 跑。
  2. 槽位只活一轮、槽名即身份(A4-2 d6be5de):BundleSlot.id / bundle.json / slot_scope / detect_slot 全删,_bundle.py 947 行 → 764 行。

写路径:出卡 → HTTP 决议 → 落单#

  1. 模型调 create_order(item_ids, 收件信息) → prepare_order_confirmation:商品按 item_id 从会话候选登记表 hydrate,入参不收标题与价格(模型重吐会把要落库的钱记错)。
  2. _save_pending(app/trade/confirmations.py:173):算 request_key,先查一次挡顺序重跑,唯一约束挡并发(两路同时查空同时插,后插那路撞键、重读拿先到的卡)。卡 expires_at = now + CONFIRMATION_TTL(30 分钟,app/trade/confirmation.py:22)。
  3. 用户点按钮 → POST /api/threads/{tid}/confirmations/{cid}/resolve:校验登录与会话归属、过期、snapshot_hash 比对;已决议的卡同决定原样返回、异决定 conflict。
  4. approved + create → _place_from_snapshot:按卡上快照落单(不回候选池 hydrate,刷新页面后池可能已空),幂等键 = operation_id,靠 orders.idempotency_key 唯一约束挡同时点两下。
  5. 下一轮装配时 hooks/context_shaping.render_trade_state_block 把未过期 pending 卡 + 本会话订单渲染成 <trade_state> 注回去——交易状态以服务端为准,不让模型凭对话记忆猜。
  6. 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-14932b481 → efb8c2f两段式 confirmed=True 改为确认卡落表、决议只走 HTTP会话级 guard 拦不住重启与刷新(旧版核对过)
2026-09-162242ef5(A1)删 TradeAgent,交易工具只留主 Agent440 会话 trade 派发 0 次
2026-09-161396ed1(A2)删定点调查及隔离检索、target_refs481 会话真实用户拆出 target_refs 0 次
2026-09-16ff023f4(A3)套装 / 跨平台改主环同轮 batch item_search并行不需要派发
2026-09-16cccdf1a(A4-1)删 dispatch_tool.py / fork_guard.py / SearchAgent / fork 预算闸;MCP 改挂 main;permissions.py 保留60 次派发全是单跳壳
2026-09-16d6be5de(A4-2)槽只活一轮、槽名即身份槽 id 是派发时代的产物
2026-09-1671d9acb(A5)前端删 fork 渲染;确认卡标「N 张待确认」同轮 batch 会落多张卡,点完第一张别以为完事
2026-09-16f8b3178 / ec46140(A6)monitor token 去「全树」口径;pyproject / context.py 措辞入库文件里「子 Agent fork」字样归零
2026-09-1694f6911终结硬停闸加 terminal_step,同批兄弟调用放行同轮双 create_order 只落一张卡
2026-09-193943ecf(S3)加 present_guide,补两个写工具放行 + 全量守栏测试present_comparison 漏登记被 REST 入口遮住
2026-09-21bfa99c3(阶段 3)确认卡 request_key 幂等;订单号撞了重分不覆盖重投出第二张卡;并发同号互相改写
2026-09-21408c99d(减法 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 / 0clone / split 两组 worker 的写工具误调data/eval/worker_mode_*.json 的 worker_write_calls✓
split 1.5 / clone 4.17worker 平均工具调用次数(每条 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)。

坑与易混点#

  1. MCP 工具不经过 harness 工具中间件(框架在 Toolkit 内部造对象,够不着):没有截断、内容过滤、白名单闸,也不发 tool_start / tool_end。所以只接只读无副作用的 server(app/agent/mcp_registry.py:23)。
  2. 环境变量名仍叫 MCP_SEARCH_*,是 SearchAgent 时代的遗留,不是角色名——现在 MCP 只挂主 Agent。
  3. 「订单不存在」与「不属于你」故意回同一个 404(usecases._load_owned):区分两者等于给出订单号探测器。
  4. create_order / cancel_order 是终结工具,调完本轮就结束——模型不能在同一轮里先出卡再接着聊。
  5. 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 章。