别让并行 agent 各自绿灯:用 PostToolBatch 收口集成验收
一个 agent 在同一轮里发出五个工具调用:改配置、改两个模块、跑一次测试、读一份日志。这些调用一起结算返回,模型随后把它们当成一整批”都成功了”的结果继续往下写。并行调用最贵的一类错误就出在这里:单点各自绿灯,合起来不成立。
Claude Code 的 hook 生命周期里有一个专门留给这种情况的触发点。PostToolBatch 的触发时机是”一整批并行工具调用全部结算完成之后、下一次模型调用之前”。[1] 它和 PostToolUse 的分工很干净:后者在单个工具调用成功后触发,前者要等整批结束。[1]
批后触发点的三个硬约束#
第一,它不支持 matcher。官方匹配表里,PostToolBatch 和 UserPromptSubmit、Stop、TaskCreated、TaskCompleted 归在同一类,标注是”无 matcher 支持,每次出现都触发”。[1] 你没法把它收窄到某个工具名上,判定逻辑只能写在脚本内部。这也意味着它天然是”每批一跑”,脚本必须便宜。
第二,它可以标 async: true,代价是失去决定权。文档把 decision、permissionDecision、continue 这三个决定类字段单独放在异步小节里说明。[1] 换句话说,走异步的批后检查只能”报告”,不能”拦人”。想拦必须在同步路径上,也就是下面要说的 Stop 那一层。
第三,异步输出回到会话的方式单独成节,涉及字段是 additionalContext 和 systemMessage,排查要靠 --debug 这类开关。[1] 别指望在批后脚本里改写模型这一轮的判断;它的价值是把”整批改动跑完之后工作区长什么样”这个事实塞进模型下一步的上下文。集成类问题最容易消失的地方,正是这个没人负责汇报的空档。
快速上手:三步搭起批后集成检查#
第一步,写检查脚本,放仓库里跟着项目走:
#!/usr/bin/env bash
# .claude/hooks/batch-gate.sh
set -euo pipefail
cat > /tmp/claude-batch-input.json # 输入 JSON 落盘,事后可查
CHANGED=$(git status --short | head -20)
STAT=$(git diff --stat | tail -3)
MSG="批后工作区快照
${CHANGED}
${STAT}"
jq -nc --arg msg "$MSG" \
'{hookSpecificOutput:{hookEventName:"PostToolBatch",additionalContext:$msg}}'
脚本只读:不回滚、不自动修、不跑全量测试。最坏情况也只是多打一段上下文。
第二步,挂到项目设置上。项目级 .claude/settings.json 可以提交进仓库给团队共享,~/.claude/settings.json 只影响本机,组织级 managed policy 由管理员控制。[1] 注意 PostToolBatch 条目里不写 matcher,因为它本来就不支持。[1]
{
"hooks": {
"PostToolBatch": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/batch-gate.sh",
"async": true,
"statusMessage": "批后快照"
}
]
}
]
}
}
${CLAUDE_PROJECT_DIR} 是钩子里可用的项目目录变量,用它比相对路径稳,尤其是子代理会在别的目录下执行 hook 的时候。[1]
第三步,确认它真的跑了。用 claude --debug 或 claude --debug-file <path> 打开调试输出,调试日志会给每个 hook 打一行成败记录,形如 Hook PostToolUse:Write (PostToolUse) success:,默认写到 ~/.claude/debug/<session-id>.txt。[1] 看到 PostToolBatch (PostToolBatch) success 才算生效;没出现就先查脚本的可执行位和 jq 是否在 PATH 上。
真正拦人要靠 Stop#
批后脚本负责让问题可见,拦下来还得靠 Stop,它在 Claude 结束回复时触发。[1] 最轻的版本是 prompt 型 hook,让一个快模型做判定,返回 {"ok": false, "reason": "..."} 就会阻止结束,并把理由喂回去让它继续干;官方给的默认超时是 30 秒。[1]
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "检查本轮改动是否都已通过验收:$ARGUMENTS。若还有未验证的改动,返回 {\"ok\": false, \"reason\": \"还差什么\"}。",
"timeout": 30
}
]
}
]
}
}
更重的版本是把 type 换成 agent,让它自己去跑测试再回来汇报,默认超时 60 秒。[1] 这一档成本明显更高,适合挂在”这批改动里出现过写操作”的场合,而不是每轮都开。
和子代理拼起来#
当这批并行调用来自子代理时,收口点有两个层次。
项目级设置里的 SubagentStart 和 SubagentStop 按 agent type 名称匹配,官方提醒插件里的子代理要用 ^my-plugin:db-agent$ 这种锚定写法,否则会误伤同名前缀的代理。[1][2] 这两个事件适合做边界动作:建连接、清临时目录、把子代理的产出清单落盘。
子代理自己的定义文件里也能写 hooks,但它只认 PreToolUse、PostToolUse 和 Stop 三个事件,其中 Stop 在运行时会被转换成 SubagentStop。[2] 所以分工是:子代理内部的自检写在自己的定义文件里,跨子代理的集成检查写在项目设置里,别混在一起。
成本上还有一条容易忽略的差别:非 fork 的子代理有自己独立的 prompt cache,fork 出来的子代理和主会话共享缓存。[2] 如果你打算让每个子代理批后都跑一遍带读文件的开销较大的检查,先想清楚这些上下文是共享的还是各付各的。
几个容易踩的边界#
异步 hook 不执行 timeout:文档明确写 Claude Code 不强制标了 async: true 的 command hook 超时。[1] 卡住的批后脚本会一直挂着,所以脚本内部要给 git 这类外部命令自己加超时。
SessionEnd 的 hook 共享 1.5 秒预算,只有设置里写了更长的超时才会被放宽到最多 60 秒。[1] 别把集成验证塞进这个事件。
PostToolBatch 每批都触发,而”批”的粒度由模型决定,一轮里可能有多次。所以脚本里只放 --stat、--short 这类廉价命令,重度验证交给 Stop 或 CI。[unverified]
最后一句要诚实:我没有找到可以引用的公开量化数据,说明批后集成检查能降低多少返工率。前面所有具体的触发时机、默认超时、匹配规则都来自官方文档,剩下关于”该把哪一类检查放哪一层”的判断是我自己的工程建议,不是有数据支撑的结论。[unverified]
Sources#
[1] https://docs.anthropic.com/en/docs/claude-code/hooks — Claude Code Hooks reference [2] https://docs.anthropic.com/en/docs/claude-code/sub-agents — Claude Code Subagents documentation