面试 Q&A — 为什么手写 Agent Harness 而不用 create_agent#
主题:
app/runtime/tool_runner.py的run_parallel_agent为什么放弃langchain.agents.create_agent,自己写 ReAct 执行器;以及围绕它长出来的整套 harness 治理(收尾轮 / 预算熔断 / 工具熔断 / 上下文压缩 / token 台账)。 全部内容对应已落地代码 + 离线单测。
Q1: create_agent 不够用吗?为什么自己写?#
三个 create_agent 给不了的东西,每个都对应生产痛点:
- read-only 并行编排。create_agent 默认串行执行 tool_calls。OnCall 诊断一轮经常
要”查指标 + 拉日志 + 搜知识库”,全串行白白浪费墙钟。手写版按
ToolMeta.concurrency_safe把 tool_calls 分批:相邻只读工具合批asyncio.gather, 写工具单独成批串行——并行但顺序受保护(写之前的读全并行,写之后的读开新批)。 - fail-isolation。同批一个工具抛错不能拖垮兄弟工具:
_safe_invoke_tool把异常转成[执行失败: ...]字符串喂回 LLM,gather 永不整批挂。 - 输出压缩下推。20KB 容器日志直接喂回 LLM 就是 context 爆炸。每个工具按
ToolMeta.max_result_chars走compact_output(JSON 树化收敛 / Markdown 保头保尾 留错误行),而不是朴素头截断。
保持与 create_agent 相同的对外契约(ainvoke({"messages": [...]})),调用方无感。
Q2: LLM 死循环调工具怎么办?裸 break 有什么问题?#
max_iters 硬上限防死循环,但撞限后裸 break 会留半成品:最后一条 AIMessage 挂着
pending tool_calls,下游拿到的是”话说一半”的对话,甚至违反 LLM API 的
tool_call/tool_result 配对约束。现在撞限走强制收尾轮(对齐 Claude Code 的 wrap-up
turn):先给每个 pending call 补 [未执行] ToolMessage,再用未 bind_tools 的裸模型
生成一轮”禁止再调工具、基于已有信息出结论”——物理上排除继续调工具的可能;收尾失败
fail-open 兜底。
Q3: token/时延预算是观测还是闸门?#
之前只是观测(emit 给前端看)。现在是闸门:ReAct 每轮进门先
evaluate_budget,超限 → emit 预算事件 + 触发上面的强制收尾轮退出。且预算看的是
全家桶总账(token_ledger.py 的 ContextVar 台账):delegate 子 Agent 内部循环烧的
token 记在同一本账上,子 Agent 花超了主循环一样熔断——没有这本账,
“主循环预算 10k、三个子 Agent 各烧 50k”就是治理盲区。配套 delegate 并发信号量
(默认 2),防 LLM 一轮 fan-out 多个子 Agent 成本指数放大。
Q4: 某个 MCP 后端挂了,LLM 反复重试怎么办?#
per-run 工具熔断器:某工具连续失败达阈值(默认 3)后,本 run 内后续调用直接短路
返回 [暂不可用] 喂回 LLM(提示改用其他工具或收尾),成功一次即复位。偶发失败不触发
(连续才算),阈值 ≤0 关闭。防的是”故障中的下游被 LLM 重试打爆 + 无效轮次烧预算”双输。
Q5: 长诊断链路 context 膨胀怎么治?#
两层压缩,作用域不同:
- 单工具级(原有):
compact_output管住单次工具输出; - 对话级(新增,对齐 Claude Code auto-compact):messages 总字符量超阈值
(默认 80k 字符)时,把早期的多轮工具往返用裸模型摘要成一条
[上下文压缩]note,保留头部任务锚点 + 尾部最新几条。关键细节:尾部起点向前回溯, 绝不把 ToolMessage 和它的 AIMessage 拆开(API 配对硬约束);摘要失败 fail-open。
Q6: 权限治理为什么要做到参数级?#
工具级白名单答不了”允许重启容器,但只允许重启 nginx,不允许动 prod-*“。Layer 3
参数级规则(permissions.py,cc-haha 同款语法):container_restart(container=prod-*)
deny 规则 / Bash(git *) 值匹配 / 纯工具名。语义要点:deny 规则放在 Mode 判定之前
——它比 ask 更强(命中 deny 连审批机会都不给),BYPASS 也不豁免;allow 规则是”声明即
白名单”(某工具一旦声明,入参必须命中一条);空规则完全 no-op。
配套治理硬化:非 dev 环境配 PERMISSION_MODE=bypass 时 Settings 实例化直接 raise——
“生产绝不打开”靠启动断言,不靠注释。
Q7: 错误分类靠字符串匹配不脆弱吗?#
脆弱。classify_error 原来靠 "timeout" in text 猜,中文异常消息/换 locale 就失效。
现在类型化异常层级(errors.py:Transient/LLMRecoverable/UserFixable/ToolUnavailable/
CodeBug)isinstance 优先——平台自有代码抛的错 100% 准确分类,决定重试/喂回 LLM/降级/
浮出的策略走对;字符串匹配降级为第三方异常的兜底,不破坏现有行为。
Q8: 一句话总结这套 harness 的设计观?#
框架给的是”能跑”,harness 治理给的是”敢上生产”。 并行编排是性能,
fail-isolation/熔断/预算闸门/收尾轮是确定性,参数级权限/启动断言是安全底线——
这三类能力都要求对执行循环有完全控制权,这就是不用 create_agent 的根本原因。
代价也诚实:要自己维护与 LangChain 消息协议的兼容(_normalize_tool_call 处理
dict/TypedDict 双形态、astream 失败回退 ainvoke),框架升级时这里是重点回归区。