6. Harness 控制面#
一句话结论:控制面是挂在 6 个 hook 点上的 25 个钩子,判据一律读已发生的事实,不维护第二套状态机。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 6 点 25 钩,按关切分 8 文件 | HOOK_POINTS 6 个;app/harness/hooks/ 8 个文件共 25 个 @harness_hook |
| 2 | 钩子只决策,适配器落地 | 钩子改 context 或抛 HookRejectSignal,换模型/拒工具/写 state 都在 adapter.py |
| 3 | 三本账:检索 8 / 站外 2 / 研究 6 | RETRIEVAL_BUDGET=8、WEB_SEARCH_TASK_QUOTA=2、RESEARCH_SEARCH_QUOTA=6,互不透支 |
| 4 | 安全闸硬拒,效率闸连拒 2 次放行 | ESCAPE_AFTER_REJECTS=2(middleware.py:33),安全闸不得声明 escape_key |
| 5 | 不摘工具,回哨兵 | 工具表每轮恒定保前缀缓存,禁用全在执行层回文案(sentinels.py) |
| 6 | 删状态机,只留两个事实位 | 四阶段机 10526d1 删掉,剩 progress_marks / force_conclude |
| 7 | 一张 run 表一个 reset | app/api/run_state.py:14 个模块级 dict → 1,9 个 reset → 1(a6d7f49) |
30 秒版#
Agent 的「怎么跑」交给 AgentScope 的主循环,「不许怎么跑」全写成钩子挂在 harness 上:6 个 hook 点、25 个钩子,按 priority 升序跑。钩子只做决策,落地由两个中间件适配器负责。闸分两种:依据事实的安全闸永远硬拒,依据推定的效率闸连拒 2 次就放行。这一年做的主要是减法——删全树口径、删四阶段状态机、把 run 状态收成一张表。
3 分钟版#
- 挂点:
on_system_prompt(装配期一次)→pre_think→pre_tool_call→post_tool_call→post_reflect→on_session_end,落点用探针实测后钉死(app/harness/adapter.py模块 docstring)。 - 分文件:按关切分 8 个文件(safety / termination / budget / sequencing / repetition / progress / drift / context_shaping),跨文件的 priority 顺序契约集中写在
hooks/__init__.py。 - 闸的两类:安全闸判精确事实(计数超了、本轮已终结、取消前没查单),硬拒;效率闸判推定,声明
escape_key,连拒 2 次闩锁放行。 - 禁用不摘工具:工具表恒定,拦截在执行层回一条写给模型的哨兵文案,前缀缓存不断。
- 注入先落 state:纠正提示用
HintBlock写进agent.state.context,再构造本轮视图,下一轮重建 messages 时字节一致。 - 预算打动机不打机制:
item_search和web_search计进同一个检索计数(按session_dir),堵一条口动机就改道另一条。 - 本年的减法:删全树命名与回退计数(e29fcba)、删四阶段状态机(10526d1)、run 状态收成一张表(a6d7f49)、deadline 下传(383ba8b)、错误分级(d40b205)。
它解决什么问题#
① 判据靠状态机还是靠事实
- 问题:四阶段机 PLANNING → SEARCHING → COMPARING → CONCLUDING 要自己维护转移顺序、同轮回退闭锁、回退事务。
- 坏法:阶段是
called_tools/total_candidates/picks_count的影子,影子与事实对不上时先坏的是真相少的那份,而且不报错。 - 修法:10526d1 整套下线,
phase_machine.py(165 行)删除;只留两个具名事实位写在GuardState上——progress_marks(进展边沿)与force_conclude(强制收尾授权)。 - 代价:漂移的「连续」类计数器必须边沿触发(首次出现进展才重置);电平触发会让
total_candidates > 0之后永远重置,漂移检测直接失效。
② run 级状态散成 14 个 dict
- 问题:十几个模块级 dict 键都是
session_dir、寿命都是一次run_agent,收尾要在 orchestrator 的finally里挨个 reset。 - 坏法:漏调一个 = 上一轮状态泄进下一轮,不报错(planner 还没跑就先按上轮的收货国走,看着一切正常)。
- 修法:a6d7f49 新增
app/api/run_state.py,_RUNS[session_dir][状态类];状态类留在拥有它的模块里,run_state 不认识任何具体类,因此不 import tools / harness,不成环。开局与finally各一次reset_run_state()。 - 代价:读路径必须走
peek_run_slot(不因读而建),写路径走run_slot;状态类字段必须全有默认值。
③ 检索计数有两条路两个上限
- 问题:
GuardState.retrieval_count+DEFAULT_RETRIEVAL_CAP是一条只有「无 session 作用域的单测」才走的回退路。 - 坏法:同一件事两个答案,线上和单测量的不是同一个东西,改一处另一处不跟。
- 修法:e29fcba 删掉那条回退路,并去掉「全树」命名(
reset_run/run_snapshot/peek_run_cost/RETRIEVAL_BUDGET_CAP)——子 Agent 已删,口径就是一次run_agent。 - 代价:单测必须自带 session 作用域,否则各模块静默降级(
run_slot返回None)。
④ 出站超时不知道主 loop 还剩多久
- 问题:外层
asyncio.timeout(MAIN_AGENT_TIMEOUT_SEC)是从外面一刀砍下来的,出站点对它一无所知。 - 坏法:主 loop 只剩 3 秒时这次检索照样按 5 秒等——那 5 秒注定等不到能被用上的结果,用户多晾一段再看到「超时」。
- 修法:383ba8b 在
run_agent入口记截止时刻(ContextVar,monotonic 刻度),各出站点取min(自身超时, 剩余),唯一入口clamp_timeout(app/api/context.py:198),只收紧不放宽。 - 代价:剩余见底给 50ms 而不是 0(0 在 httpx 是无限等、在 Qdrant 是参数错误);Qdrant 只收服务端一侧,客户端 httpx 读超时建 client 时定死。
DEADLINE_ENABLED=0回滚。
⑤ 「依赖挂了」和「参数写错了」长得一样
- 问题:工具壳把任何异常压成同一种
[error] ...文本。 - 坏法:模型按同一套反应处理——换个检索词再来一次;依赖挂了重试只是把三次超时叠成一次超长等待,最后照样收不了尾。
- 修法:d40b205 加
app/utils/dependency.py:DependencyDown+ 共用的 metadata 键,一路带到adapter.py的 ERROR 分支贴「别重试,如实告知用户」,文案点名chat_fallback。 - 代价:只包客户端调用那几行——payload →
RecallCandidate的构造失败是该修的 bug,翻成「依赖挂了」会把它藏起来;只主检索通路(Qdrant、query embedding)用,reranker / web_search 有本地降级不用。
⑥ 禁用工具该摘还是该拒
- 问题:越线后不让模型调某个工具。
- 坏法:从工具表里摘掉 → 每轮 tools 列表变了,前缀缓存从那里断开。
- 修法:工具表恒定,拦截全在执行层抛
HookRejectSignal回哨兵;raw=True的文案原样回模型不加前缀。 - 代价:模型多一次「调了才发现被拒」的往返;哨兵文案必须写清下一步,否则反复撞墙。
机制怎么跑#
hook 点与钩子分布(逐个数 @harness_hook,HEAD 634f4ab)#
| hook 点 | 落点 | 钩子数 | 有哪些(priority) |
|---|---|---|---|
on_system_prompt | agents._assemble 装配期一次 | 1 | system_prompt_append(50) |
pre_think | HarnessAgentAdapter.on_model_call 前 | 3 | liveness_watchdog(5)、tool_result_pruner(10)、budget_router(20) |
pre_tool_call | HarnessToolAdapter 内 next_handler 之前 | 7 | terminal_reached_gate(5) → tool_breaker_gate(48) |
post_tool_call | 同上,next_handler 之后 | 9 | tool_breaker_record(5)、content_filter(5)、truncate_result(10)、content_fence(15)、transition_notice(19)、result_nudges(20)、mark_terminal(30)、preference_inject(50)、drift_result_tracker(50) |
post_reflect | on_reasoning 之后 | 3 | drift_detector(20)、phase_step(40)、terminal_enforcer(60) |
on_session_end | on_reply 收尾前 | 2 | final_answer_audit(10)、strategy_feedback(90) |
合计 25。on_session_start 这个点已删(唯一的钩子 phase_init 挪回 orchestrator)。
一次工具调用#
harness.run("pre_tool_call")按 priority 跑 7 道闸(app/harness/middleware.py)。- 被拒 →
_try_escape判逃生门:声明了escape_key且连拒达 2 次就放行,否则回 ERROR chunk,同时喂 LoopDetector。 - 执行工具;抛异常记熔断失败(
ValidationError不算),返回state=ERROR时不跑post_tool_call(adapter.py:507一带的 ERROR 分支单独贴依赖提示)。 - 成功 →
after_tool_success:刷新进展时间、记called_tools、武装 autopick、采集信号。 harness.run("post_tool_call")跑 9 个钩子,顺序契约是「截断(10) 早于所有追加提示(19/20)、围栏(15) 夹在中间」。
一次模型调用#
pre_think:看门狗查活、tool_result_pruner把最旧的工具返回换占位、budget_router算档位。- 档位到 fallback 时钩子写
fallback_answer,适配器置terminal_reached,跳过模型调用,把规则文本当输出返回。 _persist_injections先用HintBlock把提示写进agent.state.context,再构造本轮视图(顺序反了前缀就失配)。post_reflect:phase_step两步固定顺序——补搜判定(污染 / 硬淘汰杀池 → 重开检索)→ 精挑连续 2 轮空则回退扩搜;terminal_enforcer发现没调终结工具就想结束时retry_nudge再推一轮。on_session_end:final_answer_audit先删哨兵文案再脱敏,strategy_feedback结账。
三本预算账(互不透支)#
| 账 | 默认值 | 计数范围 | 越线后 |
|---|---|---|---|
| 一次 run 的商品检索总量 | 8(RETRIEVAL_BUDGET,budgets.py:22) | item_search + web_search,键 session_dir | 软收敛缀强制收尾指令,再多硬挡 |
web_search 任务配额 | 2(WEB_SEARCH_TASK_QUOTA) | planner 判了 evaluate / category_intel | 落回「有候选就拦」的位置门 |
research 搜索配额 | 6(RESEARCH_SEARCH_QUOTA),单位是搜索条数 | research 内部直调 search_web | 硬挡 |
另有两条不属于检索账的闸:成本上限 $0.50(TOKEN_BUDGET_USD,并压到用户今日剩余额度以下),档位阈值剩余 0.5 / 0.2 / 0.05 → lite / minimal / fallback(model_router.py:71-73);单条工具结果截断 4000 token(truncation.py:15)。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 2026-09-14 | 60f1598 等 | 15 个文件合成 9 个关切文件,37 个钩子 | 一个关切横跨 3~4 个 hook 点(旧版核对过) |
| 09-15 | 1080a7c / 1f424f4 / cbfa6c3 / f5e8ccd | 37 → 30:删白名单与深度断言、合并 system prompt 钩子、删同参回放、删 schema 断言 | 301 会话 0 次触发 / 几乎命不中 / 纠正对象模型改不了(旧版核对过) |
| 09-15 | 9688cc9 / c59c4bb / 91dfafa | 30 → 23,hook 点 7 → 6 | 5 个预算闸合成 spend_gate + search_gate;删空的 on_session_start(旧版核对过) |
| 09-16 | A 系列 + f8b3178 | 删子 Agent;monitor 去掉「全树」token 口径 | 单环收敛后 token 就是主 loop 的累计 |
| 09-21 | 10526d1 | 删四阶段状态机(phase_machine.py 165 行),留 progress_marks / force_conclude | 阶段是事实的影子,维护不变量就成了第二套真相 |
| 09-21 | 383ba8b | 4-2 deadline 下传,clamp_timeout 收所有出站超时 | 主 loop 剩 3 秒时不该再按 5 秒等 |
| 09-21 | d40b205 | 4-3 错误分级到工具结果 metadata | 「依赖挂了」要变成「别重试」而不是让模型再试 |
| 09-21 | e29fcba | 减法 D1:检索计数只留一条路一个上限,去「全树」命名 | 回退路只有单测走,留着就是两个答案 |
| 09-22 | a6d7f49 | 减法 D4:run 状态 14 个 dict → 1 张表,9 个 reset → 1 | 漏 reset = 状态泄到下一轮且不报错 |
| 09-22 | 597656c | 减法 D5:31 个从未被设过的 env 读取改成模块常量(app 读的 env 键 253 → 222) | 只有出事要一键关的开关才值得当 env |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 6 / 25 | hook 点数 / 钩子数 | middleware.py:37 HOOK_POINTS;grep -c "^@harness_hook" app/harness/hooks/*.py | ✓ |
| 37 → 30 → 23 | 2026-09-14~15 三轮减法后的钩子数 | 旧版手册核对过(1080a7c…91dfafa 标题) | ✓(旧版核对过) |
| 2 | 效率闸连拒几次后放行 | middleware.py:33 ESCAPE_AFTER_REJECTS | ✓ |
| 8 / 2 / 6 | 检索总量 / web_search 任务配额 / research 搜索配额 | budgets.py:22、retrieval_budget.py:31,50 | ✓ |
| $0.50;0.5 / 0.2 / 0.05 | 单任务成本上限;lite / minimal / fallback 档阈值 | token_budget.py:99、model_router.py:71-73 | ✓ |
| 4000 token | 单条工具结果截断线 | truncation.py:15 | ✓ |
| 6 / 4 | LoopDetector 滑窗次数 / 同一工具出现次数阈值 | state.py:28-29 loop_window / loop_threshold | ✓ |
| 14 → 1;9 → 1 | run 级模块 dict 数 / reset 调用数 | a6d7f49 提交标题与正文 | ✓(提交正文) |
| 253 → 222 | app 读的 env 键数 | 597656c 提交标题 | 未验证(未重数) |
| 165 行 | 删掉的 phase_machine.py | 10526d1 正文 | ✓(提交正文) |
| 1463 passed / 1 xfailed | 删状态机时的全量测试 | 10526d1 正文 | ✓(提交正文) |
| 102 / 23 / 9 / 12 / 8 | test_harness / _adapter / _retrieval_budget / _token_budget / _deadline 测试函数数 | grep -c "def test_" | ✓ |
追问 10 题#
Q1. 规则写进 prompt 不就行了?
A:prompt 只能劝,控制面能拦。取消订单最典型:模型会从「把上次那单取消了」编一个订单号,编出来的号可能命中另一张真单,所以 trade_sequence_gate(12) 判本轮有没有 query_order,没有就硬拒(hooks/sequencing.py)。
Q2. 为什么删掉四阶段状态机? ⚠
A:阶段只是 called_tools / total_candidates / picks_count 的影子。影子一旦自己维护不变量,就成了必须与事实对账的第二套真相,对不上时先坏的是真相少的那份。现在判据一律读已发生的事实(10526d1;hooks/progress.py 模块 docstring)。
Q3. 删了状态机,收尾资格怎么保证?
A:底线没变,仍是 pre_tool_call 的 phase_check(20) 硬拒:无候选 / 本轮没规划 / 本轮没精挑就不许出清单。force_conclude 置上后只放开「本轮没规划过」那条——否则一边注入「立即收尾」一边拦下它,模型被卡死;「本轮没精挑」仍硬拒,它判的是清单有没有来源。
Q4. 效率闸的逃生门在同轮并行调用下怎么稳定? ⚠
A:计数记在 GuardState.gate_reject_counts,同一条 assistant 消息里的并行调用按批次起点快照统一裁决,同批多次拒绝只算一次;到 2 次后放行且计数不清零,是闩锁语义(middleware.py:_try_escape)。
Q5. 安全闸为什么不接逃生门?
A:安全闸判的是精确事实(计数超了、本轮已终结、取消前没查单),不存在「立错墙」。HookRejectSignal 的 docstring 明确要求这类闸不得声明 escape_key。
Q6. 三本预算账为什么不合成一本? ⚠
A:吃的不是同一种成本。research 一次发 3 搜,是 web_search 配额(2)的 1.5 倍,共用一份额度两边互相饿死;而 research 不产候选,混进检索总额只会挤掉 item_search 的额度,且「停止检索立即收尾」的哨兵对它不成立。挤气球风险由它自己封顶兜住(retrieval_budget.py 分账长注释)。
Q7. deadline 为什么不直接把出站超时统一设小?
A:一个出站点该等多久是它自己的知识(对面正常响应要多久),deadline 只管「再等也没意义了」。所以 clamp_timeout 只收紧不放宽,剩余见底给 50ms 而不是 0(0 在 httpx 是无限等、在 Qdrant 是参数错误)。
Q8. 错误分级为什么挂在 adapter 而不是 result_nudges? ⚠
A:工具内部报错本来就不跑 post_tool_call,挂 result_nudges 等于不生效。所以贴在 adapter.py 的 ERROR 分支,且优先级高于 LoopDetector 的循环提示——那条要撞够阈值才说话,而依赖不可用第一次就该停(d40b205 正文)。
Q9. run 状态收成一张表,为什么不用 ContextVar?
A:asyncio 子任务创建时拷贝一份 context,子任务里的 set 不回传父 loop。同轮 batch 的几个工具各跑在自己的子任务里,用 ContextVar 会静默漏计;session_dir 由 thread_scope 设好后被子任务继承,按同一 key 自增才数得准(run_state.py 模块 docstring)。
Q10(压力题). 钩子出异常就跳过,安全钩子挂了岂不是裸奔? ⚠
A:是有意接受的风险,边界写在 HarnessMiddleware.run 的 docstring:治理代码的 bug 不该拖垮主链路。现存安全钩子(content_filter / truncate_result / final_answer_audit)都是集合、正则、字符串处理,不碰 IO 和 LLM。约定是:将来引入带 IO / LLM 依赖的安全钩子,必须在钩子内部 catch 后主动抛 HookRejectSignal。
坑与易混点#
run_state.py在app/api/,不在app/harness/——它存的不只是 harness 的状态(还有_BundleState/_CandidateState),放 api 层才不与 tools / harness 成环。hooks/__init__.py模块 docstring 仍写「budget.py 预算:检索 / fork / token 三类额度闸」,fork 那档随 A 系列删子 Agent 已没了;同一段还写 repetition 含「同参数回放」,那个钩子 09-15(cbfa6c3)已删。以代码为准。hooks/__init__.py里 progress 一行仍叫「检索进度机:转移 / 回退」,四阶段机 10526d1 已删,现在只剩phase_step的两步与两个事实位。- 漂移的「连续」计数器是边沿触发(进展首次出现才重置),写成电平触发会让漂移检测在有候选之后永远失效。
GuardState.retrieval_count已随 e29fcba 删除;面试提「检索预算住哪」答app/harness/retrieval_budget.py按session_dir聚合,别提那条回退路。
本章和别章的接口#
- 终结工具集、看门狗与
terminal_enforcer的完整规则在第 2 章。 - autopick 走
after_tool_success同一条 post_tool_call 管线,检索侧细节在第 4 章。 - 前缀缓存与
cache_control、压缩策略在第 7 章;模型档位落到哪个模型在第 9 章。