《Learn Claude Code》复习
原文出处 :learn.shareai.run/zh/(开源教程 Learn Claude Code ,用 20 个可运行的 Python 教学脚本,从 102 行到 1708 行,逐层还原一个 AI 编程 Agent 的 harness(运行时外壳/驱动层) 是怎么搭起来的)。
📖 给谁看 & 建议阅读顺序
| 你是谁 | 建议读法 | 预期收获 |
|---|---|---|
| 只会调 API 的开发者(没写过 Agent) | 只读「序章」+「第一部分 s01--s04」+「终章」 | 搞懂 Agent 和普通 chatbot 的本质差别:一个循环 |
| 写过 Agent demo,想做生产级 | 通读全文,重点看 s03/s08/s11 三章的"真实源码对照" | 知道教学 demo 和线上系统差在哪:权限、压缩、错误恢复 |
| 在做 Agent 框架 / 多智能体 | 重点看第二部分 s05--s10 与第四部分 s15--s19 | 上下文工程 + 多 Agent 通信协议的完整骨架 |
| 只想要速查手册 | 直接跳到文末「附录 A 常量速查表」「附录 B 20 章总表」 | 一张表带走所有阈值与默认值 |
一句话主线 :全书 20 章 = 在 s01 那 30 行 while True 上,挂了 19 个机制。循环本身从头到尾没变过。
目录
- 序章:全书在讲什么
- [第一部分 · 让 Agent 动起来(s01--s04)](#第一部分 · 让 Agent 动起来(s01–s04))
- [第二部分 · 让 Agent 不迷失(s05--s10)](#第二部分 · 让 Agent 不迷失(s05–s10))
- [第三部分 · 让 Agent 不崩、能等、能定时(s11--s14)](#第三部分 · 让 Agent 不崩、能等、能定时(s11–s14))
- [第四部分 · 让 Agent 组队干活(s15--s19)](#第四部分 · 让 Agent 组队干活(s15–s19))
- [终章 · s20 一个完整 Agent Turn](#终章 · s20 一个完整 Agent Turn)
- [附录 A:常量速查表](#附录 A:常量速查表)
- [附录 B:20 章总表](#附录 B:20 章总表)
序章:全书在讲什么
先讲一个具体场景
你对大模型说:"帮我看看目录下有哪些文件,然后执行 XXX.py。"
模型很爽快地吐出一条 ls -la。然后呢?然后它停了。
它不会自己去跑这条命令,也看不到 ls 的输出后再接着推理。于是你手动复制、粘贴回对话框,它再给你一条 python XXX.py,你再跑一遍、再粘回去。
每一个来回,你都在充当"人肉胶水"。
所谓 Agent,本质上就是把这层胶水写成代码。而写出来的东西,出人意料地短------30 行。
全书唯一的骨架:那 30 行
python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use": # 模型说"我做完了"
return
results = [] # 模型说"我要用工具"
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results}) # 喂回去,继续
这 30 行里藏着全书最重要的分工:
模型负责决策 (要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。
后面 19 章,全部是在这个循环上"挂钩子",循环本身一行没改。
全书分层地图
#mermaid-svg-C6mmsmOLOURbC0nL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-C6mmsmOLOURbC0nL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-C6mmsmOLOURbC0nL .error-icon{fill:#552222;}#mermaid-svg-C6mmsmOLOURbC0nL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-C6mmsmOLOURbC0nL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-C6mmsmOLOURbC0nL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-C6mmsmOLOURbC0nL .marker.cross{stroke:#333333;}#mermaid-svg-C6mmsmOLOURbC0nL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-C6mmsmOLOURbC0nL p{margin:0;}#mermaid-svg-C6mmsmOLOURbC0nL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-C6mmsmOLOURbC0nL .cluster-label text{fill:#333;}#mermaid-svg-C6mmsmOLOURbC0nL .cluster-label span{color:#333;}#mermaid-svg-C6mmsmOLOURbC0nL .cluster-label span p{background-color:transparent;}#mermaid-svg-C6mmsmOLOURbC0nL .label text,#mermaid-svg-C6mmsmOLOURbC0nL span{fill:#333;color:#333;}#mermaid-svg-C6mmsmOLOURbC0nL .node rect,#mermaid-svg-C6mmsmOLOURbC0nL .node circle,#mermaid-svg-C6mmsmOLOURbC0nL .node ellipse,#mermaid-svg-C6mmsmOLOURbC0nL .node polygon,#mermaid-svg-C6mmsmOLOURbC0nL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-C6mmsmOLOURbC0nL .rough-node .label text,#mermaid-svg-C6mmsmOLOURbC0nL .node .label text,#mermaid-svg-C6mmsmOLOURbC0nL .image-shape .label,#mermaid-svg-C6mmsmOLOURbC0nL .icon-shape .label{text-anchor:middle;}#mermaid-svg-C6mmsmOLOURbC0nL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-C6mmsmOLOURbC0nL .rough-node .label,#mermaid-svg-C6mmsmOLOURbC0nL .node .label,#mermaid-svg-C6mmsmOLOURbC0nL .image-shape .label,#mermaid-svg-C6mmsmOLOURbC0nL .icon-shape .label{text-align:center;}#mermaid-svg-C6mmsmOLOURbC0nL .node.clickable{cursor:pointer;}#mermaid-svg-C6mmsmOLOURbC0nL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-C6mmsmOLOURbC0nL .arrowheadPath{fill:#333333;}#mermaid-svg-C6mmsmOLOURbC0nL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-C6mmsmOLOURbC0nL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-C6mmsmOLOURbC0nL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C6mmsmOLOURbC0nL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-C6mmsmOLOURbC0nL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C6mmsmOLOURbC0nL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-C6mmsmOLOURbC0nL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-C6mmsmOLOURbC0nL .cluster text{fill:#333;}#mermaid-svg-C6mmsmOLOURbC0nL .cluster span{color:#333;}#mermaid-svg-C6mmsmOLOURbC0nL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-C6mmsmOLOURbC0nL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-C6mmsmOLOURbC0nL rect.text{fill:none;stroke-width:0;}#mermaid-svg-C6mmsmOLOURbC0nL .icon-shape,#mermaid-svg-C6mmsmOLOURbC0nL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C6mmsmOLOURbC0nL .icon-shape p,#mermaid-svg-C6mmsmOLOURbC0nL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-C6mmsmOLOURbC0nL .icon-shape .label rect,#mermaid-svg-C6mmsmOLOURbC0nL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C6mmsmOLOURbC0nL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-C6mmsmOLOURbC0nL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-C6mmsmOLOURbC0nL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} s01 Agent Loop
30 行 while True
(全书地基,永不改变)
执行层 s02--s04
工具分发 · 权限 · Hooks
上下文层 s05--s10
计划 · 子Agent · 技能 · 压缩 · 记忆 · Prompt
韧性/调度层 s11--s14
错误恢复 · 任务图 · 后台 · 定时
协作层 s15--s19
团队 · 协议 · 自治 · 隔离 · MCP
s20 综合
所有机制挂回同一个循环
代码量增长曲线(能直观看出"复杂度花在哪"):
| 章节 | s01 | s05 | s08 | s12 | s15 | s18 | s20 |
|---|---|---|---|---|---|---|---|
| LOC | 102 | 236 | 414 | 297 | 745 | 802 | 1708 |
| 工具数 | 1 | 6 | 9 | 8 | 14 | 18 | 32 |
注意 s08→s12 的回落:教程为了讲清单点,每章会刻意精简掉前面章节的某些机制。真实 Agent 不会这样拆开跑------这正是 s20 存在的理由。
第一部分 · 让 Agent 动起来(s01--s04)
这一层回答:模型想干活,怎么让它真的干到,且不闯祸。
s01 · Agent Loop ------ "One loop & Bash is all you need"
Harness 层:循环 ------ 模型与真实世界的第一道连接。
解决什么:上面说的"人肉胶水"问题。
怎么做 :整个循环只有两个信号 ,靠 stop_reason 二分:
| 信号 | 含义 | 循环动作 |
|---|---|---|
stop_reason == "tool_use" |
模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
stop_reason != "tool_use" |
模型说"我做完了" | 退出循环 |
唯一的数据结构 就是 messages 这个不断追加的数组。工具结果以新的 user 消息身份回到数组里,循环继续。
📌 真实 Claude Code 差在哪(基于 src/query.ts,1729 行)
| 教学版 | 真实 CC |
|---|---|
看 response.stop_reason |
不看 。stop_reason 在流式响应里不可靠(可能还没更新,但内容里已有 tool_use 块)。CC 用 needsFollowUp 布尔标志(query.ts:554-558、:830-834),源码注释直接写着 // stop_reason === 'tool_use' is unreliable. |
只用 messages |
State 对象 10 个字段 :messages、toolUseContext(s02)、autoCompactTracking(s08)、maxOutputTokensRecoveryCount(上限 3,s11)、hasAttemptedReactiveCompact(s08)、maxOutputTokensOverride(8K→64K,s11)、pendingToolUseSummary(s08)、stopHookActive(s04)、turnCount、transition(s11) |
| 1 条退出路径 | 十几条退出/继续路径:blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry...... |
| 串行执行工具 | StreamingToolExecutor(query.ts:561):模型还在生成时就开始并行执行 |
一句话 :1729 行的
query.ts,核心就是那 30 行while True。所有复杂字段和退出路径都是保护机制。
s02 · Tool Use ------ "加一个工具,只加一行"
Harness 层:工具分发 ------ 扩展模型能触达的边界。
解决什么 :只有 bash 时,模型想"读这个文件",却必须先翻译 成 cat path/to/file。多一层翻译 = 多烧 token + 更容易拼错。
怎么做 :s01 的循环一行没改 ,只把硬编码的 run_bash() 换成查表分发:
python
TOOL_HANDLERS = {"bash": run_bash, "read_file": run_read,
"write_file": run_write, "edit_file": run_edit, "glob": run_glob}
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS[block.name] # ← 唯一的改动
output = handler(**block.input)
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
加一个工具 = 两件事 :① TOOLS 数组加一条 JSON Schema(告诉模型"我能做什么");② TOOL_HANDLERS 字典加一行映射。
⚠️ 留下的坑 :safe_path 只保护 file tools,bash 完全裸奔 → 引出 s03。
📌 真实 CC 差在哪
isConcurrencySafe(input)按具体输入判断,不是"只读 vs 写" :FileRead/Glob都 true;Bash lstrue 但Bash rmfalse ;TaskCreate是 false/true------它改状态,但每次写不同文件,所以可以并发。- 分区算法
partitionToolCalls()(toolOrchestration.ts:91-115)不是简单分两组,而是按连续 block 分批 :
[read A, read B, glob *.py, bash "rm x", read C]→ batch1(并发) → batch2(串行: rm) → batch3(并发: read C)。batch 内并发(有上限),batch 间严格串行。 - 5 步验证管线 (
toolExecution.ts):Zod schema 验证 →validateInput()→ PreToolUse hooks → 权限检查 →tool.call()。 maxResultSizeChars:结果超阈值落盘,模型只看预览 + 路径。而 FileRead 特设为Infinity------否则会出现"读文件结果被落盘 → 模型再读那个落盘文件 → 再落盘"的无限循环。这种"一个常量背后的血泪史",是全书最有价值的部分之一。
s03 · Permission ------ 安全不能靠信任模型,要靠代码
Harness 层:权限 ------ 在工具执行前加一道门。
解决什么 :你让它"清理一下项目",它可能真的执行 rm -rf /。
怎么做 :三道闸门,顺序固定 ------ 硬拒绝优先,软询问次之,都没命中就放行(日常操作大多走最后这条):
python
def check_permission(block) -> bool:
if block.name == "bash": # 闸门1: 硬拒绝
reason = check_deny_list(block.input.get("command", ""))
if reason: print(f"\n⛔ {reason}"); return False
reason = check_rules(block.name, block.input) # 闸门2: 规则匹配
if reason: # 闸门3: 用户审批
if ask_user(block.name, block.input, reason) == "deny":
return False
return True
- 闸门 1 :
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]------教程自己标注了:简单子串匹配不是可靠安全机制,命令变体和 shell 展开能绕过。 - 闸门 2 :写类工具检查路径是否
is_relative_to(WORKDIR);bash 检查["rm ", "> /etc/", "chmod 777"]。 - 闸门 3 :
input(" Allow? [y/N] ")------ 默认拒绝,回车或任何非 y/yes 都回落 deny。 - 被拒绝时不是崩溃 ,而是把
"Permission denied."当tool_result喂回模型,让它自己想别的办法。
📌 真实 CC 差在哪
| 教学版 | 真实 CC |
|---|---|
| 3 种结果 | 4 种 :allow / deny / ask / passthrough(工具不表态,交给通用管线) |
| 1 个 deny list | 规则来自 8 个来源 (types/permissions.ts:54-62):user / project / local / flag / policy / cliArg / command / session,优先级 user < project < local < flag < policy |
| 手搓校验 | 6 阶段管线(toolExecution.ts:599-1745),含 backfillObservableInput()、resolveHookPermissionDecision();且「内容相关 ask 规则」与「安全检查违规」不可绕过 |
| 简单子串匹配 | YoloClassifier:auto 模式不每次弹窗,用分类器 LLM 判断;连续拒绝太多次则回退人工 |
| --- | 权限冒泡 :子 Agent 的 permissionMode: 'bubble'(forkSubagent.ts:50),弹窗冒泡到父终端,而不是在子进程里静默拒绝 |
⚠️ 一个反直觉点:isDestructive()(Tool.ts:405-406)纯 UI 展示 ------只在工具列表显示 [destructive] 标签,不参与权限决策。
s04 · Hooks ------ "挂在循环上,不写进循环里"
Harness 层:hook ------ 扩展点不侵入循环。
解决什么 :每加一个检查就要改 agent_loop 本体:log_to_file()、check_permission()、notify_slack()、auto_git_add()......很快循环就认不出来了。教程的总结一针见血:
你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是稳定的核心,扩展应该挂在外面。
怎么做 :注册表 + 触发器,短路约定:回调返回非 None 就立即中断后续。
python
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # ≠ None → hook 说"停"
return result
return None
四个事件覆盖一个完整 agent cycle:
| 事件 | 触发时机 | 返回非 None 的含义 |
|---|---|---|
UserPromptSubmit |
用户输入后、进 LLM 前 | 教学版不使用 |
PreToolUse |
工具执行前 | 阻止本次执行(该值作为 tool_result 喂回模型) |
PostToolUse |
工具执行后 | 教学版不使用 |
Stop |
循环即将退出时 | 强制续跑(作为新 user 消息追加并 continue) |
循环里唯一的改动,就是把 s03 的 check_permission(block) 换成 trigger_hooks("PreToolUse", block)。
📌 真实 CC 差在哪
- 不是 4 个事件,是 27 个 (
coreTypes.ts:25-53):涵盖会话(SessionStart/SessionEnd/Stop)、交互(Notification/PermissionRequest)、子 Agent(SubagentStart/Stop)、压缩(PreCompact/PostCompact)、团队(TeammateIdle/TaskCreated/TaskCompleted)等。 HookResult14 个字段 (types/hooks.ts:260-275),常用的有blockingError(注入对话让模型自纠)、permissionBehavior、updatedInput(直接修改工具输入 )、additionalContext。- 🔒 最关键的安全不变式 (
toolHooks.ts:325-331):hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则。即使用户自己的 hook 脚本说"允许",只要在 settings.json 里禁用了该工具,依然被阻止。教学版没这层,教程直言"在生产环境中会形成安全漏洞"。 - 🔁
stopHookActive防无限循环 (query.ts:212,1300):Stop hook 产生 blockingError 时下一轮带此标志重入,后续迭代 Stop hook 就不再触发------避免"自纠→报错→再自纠→再报错"永不停机。
第二部分 · 让 Agent 不迷失(s05--s10)
这一层回答:对话越来越长、任务越来越复杂,怎么让它不跑偏、不忘事、不爆上下文。 这是全书的精华。
s05 · TodoWrite ------ 没有计划的 Agent 走哪算哪
解决什么 :任务"把所有 Python 文件改成 snake_case,然后跑测试,修好失败"。Agent 改了 3 个文件、跑测试、发现 2 个失败就开始修------修着修着忘了最初目标是重命名。对话越长越严重:工具结果不断填进上下文,系统提示的影响力被稀释。
怎么做 :新增 todo_write 工具,走完全不变的 dispatch 机制。它只是把列表存进全局变量并渲染到终端:
python
def run_todo_write(todos: list) -> str:
global CURRENT_TODOS
CURRENT_TODOS = todos
for t in CURRENT_TODOS:
icon = {"pending": " ", "in_progress": "▸", "completed": "✓"}[t["status"]]
print(f" [{icon}] {t['content']}")
return f"Updated {len(CURRENT_TODOS)} tasks"
关键洞察 :
todo_write不给 Agent 增加任何执行能力 。它增加的是规划能力。
Nag reminder :循环里计数,rounds_since_todo >= 3 时追加 <reminder>Update your todos.</reminder>。教程明确标注:这是教学机制,CC 源码中没有这个固定轮数逻辑。
📌 真实 CC:两套任务系统并存
isTodoV2Enabled() 切换------交互式会话默认 Task System(V2,即 s12) ,非交互/SDK 默认 TodoWrite(V1) ;环境变量 CLAUDE_CODE_ENABLE_TASKS 可强制开 V2。Task System 相对 TodoWrite 的五项增量:文件持久化、依赖图、proper-lockfile 并发安全、四个独立工具、生命周期 hooks。
s06 · Subagent ------ 大任务拆小,每个小任务干净的上下文
解决什么 :Agent 修 bug 读了 30 个文件、聊了 60 轮,messages 涨到 120 条,中间过程占着上下文,它越来越健忘。
怎么做 :新增 task 工具。子 Agent 拿到全新的 messages ,配独立的 SUB_SYSTEM,最多跑 30 轮 safety limit,结束后只回传结论。
python
def spawn_subagent(description: str) -> str:
sub_tools = [{"name":"bash",...}, ...] # 注意:没有 task 工具
messages = [{"role": "user", "content": description}] # 全新 messages[]
for _ in range(30): # safety limit
...
return extract_text(messages[-1]["content"]) # 只回传结论
三个设计取舍:
- 上下文隔离 ------ 中间过程不污染主上下文
- 只回传结论 ------ 不是整个 messages 列表
- 禁止递归 ------ 子 Agent 工具集是 bash/read/write/edit/glob,没有 task
🔑 一个容易踩的坑:上下文隔离 ≠ 权限隔离 。子 Agent 的工具调用仍走 PreToolUse/PostToolUse hook;写文件、跑命令的副作用依然留在工作目录。
教程类比 :"你修 bug 的时候,会开一个新终端来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。"
📌 真实 CC 差在哪
- 不是一种模式,是三种:Normal Subagent、Fork Subagent、General-Purpose。
- Fork 模式是为了共享 prompt cache (
forkSubagent.ts:60-71):forkedAgent.ts:57-68要求 system prompt / tools / model / messages 前缀 / thinking config 五者字节级一致才能命中缓存。 - 隔离的精确粒度 (
createSubagentContext()):abortController是新的 child controller,但readFileState从父克隆(文件读取状态共享)。 - 递归防护不是"不给工具" :靠
isInForkChild()检查历史里有无FORK_BOILERPLATE_TAG;constants/tools.ts:36-46中Agent默认在被禁集合。
s07 · Skills ------ 用到时再加载,别全塞 prompt 里
解决什么 :React 规范 2000 行 + SQL 指南 1500 行 + API 文档 3000 行,全拼进 SYSTEM 就是 6500 行。改个 CSS 颜色也带着 SQL 文档,99% 无关,纯烧 token。
怎么做 :两级加载。
| 级别 | 内容 | 成本 | 存活周期 |
|---|---|---|---|
| 第一级 · 目录 | name + description 清单注入 SYSTEM |
~100 tokens/skill | 每轮都带 |
| 第二级 · 内容 | load_skill(name) 查注册表返回正文 |
~2000 tokens/skill | 作为 tool_result 进 messages,直到 compact |
python
def _scan_skills(): # 启动时扫描 skills/*/SKILL.md
for d in sorted(SKILLS_DIR.iterdir()):
meta, body = _parse_frontmatter((d / "SKILL.md").read_text())
SKILL_REGISTRY[meta.get("name", d.name)] = {"description": ..., "content": raw}
def load_skill(name: str) -> str: # 查表,不是拼路径 → 无路径遍历风险
skill = SKILL_REGISTRY.get(name)
return skill["content"] if skill else f"Skill not found: {name}"
金句 :"按需加载解决了'不该提前带的不要带',compact 解决'该丢的怎么丢'。" ------ s07 管入口 ,s08 管出口。
📌 真实 CC 差在哪
- 技能来源不是一个目录 :user(
~/.claude/skills/)、project(.claude/skills/)、--add-dir、legacy commands、bundled、plugin、MCP 远程技能,还有 conditional skills (带pathsfrontmatter,按当前文件路径激活)。 - frontmatter 字段 :
name/description/when_to_use/allowed-tools/context(inline|fork)/model/hooks/paths/user-invocable。 - 目录预算 :约上下文窗口的 1%,上限 8000 字符。
- 🔑 一个教学版的错误简化 :
SkillTool返回的 tool_result 文本其实只是"Launching skill: {name}",真正内容通过newMessages注入对话。教学版把两者合并了。
s08 · Context Compact ------ 上下文总会满,要有办法腾地方
解决什么 :读了 30 个文件、跑了 20 条命令,全堆在 messages 里,上下文窗口满了 API 直接报 prompt_too_long。
怎么做 :核心设计一句话------便宜的先跑,贵的后跑。每轮 LLM 调用前跑三个 0-API 预处理器,超阈值才花 1 次 LLM 摘要。
| 层 | 手段 | 触发条件 | 动作 |
|---|---|---|---|
| L1 | snip_compact |
消息数 > 50 | 保留头 3 + 尾 47,中间插 [snipped N messages...] 占位 |
| L2 | micro_compact |
KEEP_RECENT_TOOL_RESULTS = 3 |
除最近 3 条外,长度 > 120 字符的旧 tool_result 换成一行占位符 |
| L3 | tool_result_budget |
单条 user 消息内 tool_result 总和 > 200000 字符 | 按大小降序落盘 .task_outputs/,留标记 + 前 2000 字符预览 |
| L4 | compact_history |
上面还不够 | 先写 transcript JSONL,再 LLM 摘要,整体替换为一条 [Compacted];连续失败 3 次熔断 |
⚠️ 顺序不能换 :budget(L3) → snip(L1) → micro(L2) 。因为 micro 会把大 tool_result 变成一行占位符,budget 必须在之前把完整内容落盘,否则内容就丢了。
python
KEEP_RECENT_TOOL_RESULTS = 3
def micro_compact(messages):
tool_results = collect_tool_result_blocks(messages)
if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
return messages
for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
if len(block.get("content", "")) > 120:
block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
return messages
应急通道 reactive_compact :捕获 PromptTooLongError → 摘要 + 保留尾部 5 条消息(同样要避开孤立的 tool_result),重试上限 1 次。另有 compact 工具让模型主动调用。
📌 真实 CC 差在哪
- 真实顺序 (
query.ts:379-468):applyToolResultBudget(L379) →snipCompact(L403) →microcompact(L414) →contextCollapse(L441,教学版没有) →autoCompact(L454)。教学版 L1/L2/L3/L4 的编号与实际顺序不一致,读的时候别被编号误导。 - 阈值算法不同 :教学版用字符数估算,CC 用精确 token =
contextWindow - maxOutputTokens - 13_000。 - 关键常量 :
AUTOCOMPACT_BUFFER_TOKENS=13,000、MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES=3、MAX_OUTPUT_TOKENS_FOR_SUMMARY=20,000、POST_COMPACT_TOKEN_BUDGET=50,000、POST_COMPACT_MAX_FILES_TO_RESTORE=5、POST_COMPACT_MAX_TOKENS_PER_FILE=5,000、时间型 micro compact 间隔 60 分钟。 - snip 仅主线程启用 ,实现不在开源仓库(
HISTORY_SNIPfeature gate);头 3 + 尾 47 是教学版参数。 - 压缩 prompt 双防呆 :开头
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.,末尾再来一遍 REMINDER;要求先<analysis>再<summary>,analysis 在格式化时剥离。 - 压缩后按预算恢复最近文件 (≤5 个 × 5K token,总 50K);重复读未变文件返回
FILE_UNCHANGED_STUB。
s09 · Memory ------ 压缩会丢细节,要有一层不丢的
解决什么 :autoCompact 会把"用 tab 缩进不要用空格"压缩成"用户有代码风格偏好"这种废话;新开会话连摘要都没了。LLM 无持久状态 ,需要一层不参与压缩、跨会话保留的存储。
怎么做:
- 存储 :
.memory/*.md,YAML frontmatter(name/description/type),正文里写**Why:**/**How to apply:**。MEMORY.md是索引,一行一个链接。四类:user (你是谁)、feedback (怎么做事)、project (正在发生什么)、reference(东西在哪找)。 - 加载两条路径 :① 索引常驻 SYSTEM(可被 prompt cache 缓存);②
select_relevant_memories()做一次轻量 side-query (max_tokens=200,返回 JSON 数组下标),最多 5 条 注入当前 user turn;side-query 失败降级到关键词匹配。 - 写入 :
extract_memories()只在stop_reason != "tool_use"(一段对话告一段落)时跑,取messages[-10:]、dialogue[:4000]。 - 整理 :
CONSOLIDATE_THRESHOLD = 10,文件数达标才让 LLM 去重合并。
设计要点 :索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn ------ 按 filename/description 匹配当前对话,不破坏 cache。
📌 真实 CC 差在哪
- 选记忆用的是 Sonnet 本身,不是 embedding (
findRelevantMemories.ts:18-24),最多 5 个,不确定就不选。 - 注入预算 (
utils/attachments.ts:269-288):每文件 ≤200 行 / 4096 字节 ,单 session 总 60KB ;MEMORY.md上限 200 行 / 25KB。 - 提取在 stop hook 里 fire-and-forget ,实际执行是 forked agent :受限权限、
skipTranscript: true、maxTurns: 5,且有重叠保护(主 Agent 已写过就跳过)。 - Dream(自动整理)四层门控 :时间 ≥ 24 小时 / 扫描节流 / 会话 ≥ 5 个 transcript /
.consolidate-lock文件锁(1 小时后自动过期)。
s10 · System Prompt ------ prompt 是组装出来的,不是写死的
解决什么 :s01 的 SYSTEM = f"You are a coding agent at {WORKDIR}." 到 s09 已经要追加 todo、skills、memories 一堆指令。三个痛点:换项目要重写整个 prompt、改一处影响全局、每次请求带全部内容浪费 token。
怎么做 :PROMPT_SECTIONS 字典分段 + assemble_system_prompt(context) 按需拼接 + 缓存。
python
def get_system_prompt(context: dict) -> str:
global _last_context_key, _last_prompt
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
if key == _last_context_key and _last_prompt:
return _last_prompt # 命中缓存,避免重复拼字符串
_last_context_key = key
_last_prompt = assemble_system_prompt(context)
return _last_prompt
四条原则:
- 四个 section 两种策略:identity / tools / workspace 始终加载 ,memory 按需 (依据
.memory/MEMORY.md是否存在)。 - section 是否加载取决于真实状态 (工具是否注册、文件是否存在),不是在消息里搜关键词。
- 缓存 key 用
json.dumps(...)而不是hash()------Python 内置hash()有进程随机化,且遇到 list/dict 会报unhashable type。 - 注意这只是"避免重复拼字符串",和 CC 的 API prompt cache 不是一回事。
为什么不全加载? 两条理由:token 有成本(system prompt 每轮计费);信息越少 LLM 越专注(无关指令是噪音)。
📌 真实 CC 差在哪
- section 数量不固定 ,受 feature flag、output style、用户类型、token 预算影响。静态 :identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency;动态 :session_guidance、memory、language、output_style、
mcp_instructions、scratchpad、token_budget...... mcp_instructions是唯一易失性 section,因为 MCP server 可在轮次之间连接/断开。- 三层缓存 (教学版只有一层):① lodash memoize 缓存
getSystemContext/getUserContext;②STATE.systemPromptSectionCache缓存动态 section,/clear或/compact时清除;③ API 层splitSysPromptPrefix()按SYSTEM_PROMPT_DYNAMIC_BOUNDARY分块到不同 cache scope。 - 规模 :标准交互模式 system prompt 核心约 20--30KB ;
CLAUDE_CODE_SIMPLE约 150 字符(整个 prompt 2 行)。
第三部分 · 让 Agent 不崩、能等、能定时(s11--s14)
这一层回答:现实世界会报错、会慢、需要定时,怎么扛。
s11 · Error Recovery ------ 错误不是终点,是重试的起点
解决什么 :Agent 跑到一半报个 Error: 529 overloaded 就整体崩溃------没重试、没换模型、没精简上下文。
教程原话 :"一个不处理错误的 Agent 就像一个一碰就熄火的车。"
怎么做 :把 LLM 调用包进 try/except,按错误类型分三条恢复路径,恢复后一律 continue 回到循环开头重调。
| 路径 | 触发 | 动作 | 上限 |
|---|---|---|---|
| 1. 输出截断 | stop_reason == "max_tokens" |
不把截断输出追加进 messages ,把 max_tokens 从 8000 抬到 ESCALATED_MAX_TOKENS = 64000(8 倍)重发;还不够才 append + CONTINUATION_PROMPT 续写 |
升级只有一次机会,续写最多 3 次 |
| 2. 上下文超限 | PromptTooLongError |
reactive_compact(messages) |
has_attempted_reactive_compact 只给一次机会 |
| 3. 瞬态故障 | 429 / 529 | with_retry() 指数退避 + 抖动;连续 3 次 529 且有 FALLBACK_MODEL 则切模型 |
MAX_RETRIES = 10 |
python
def retry_delay(attempt, retry_after=None):
if retry_after:
return retry_after
base = min(500 * (2 ** attempt), 32000) / 1000
return base + random.uniform(0, base * 0.25) # +25% 抖动,防重试对齐
取舍背后的判断 :"超过就退出------继续续写也不会有实质产出" ;"压缩过了还是超限,只能退出------再压缩也不会变小。" 知道什么时候该放弃,和知道什么时候该重试一样重要。
📌 真实 CC 差在哪
- 不是 3 条,是十几条 reason/transition :
completed、next_turn、max_output_tokens_escalate、max_output_tokens_recovery、reactive_compact_retry、prompt_too_long、collapse_drain_retry、model_error、image_error、aborted_streaming、aborted_tools、stop_hook_blocking、stop_hook_prevented、hook_stopped、token_budget_continuation、blocking_limit、max_turns。 - 退避精确公式 (
withRetry.ts:530-548):min(500 × 2^(attempt-1), 32000) + random(0~25%),第 1/2/4/7+ 次分别 500ms/1000ms/4000ms/32000ms。 - 续写提示原文 (
query.ts:1225-1227):"Output token limit hit. Resume directly --- no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces." - Diminishing Returns 检测 (
tokenBudget.ts:60-62):连续 3 次 continuation 且 token 增量 < 500 就停。
s12 · Task System ------ 大目标拆成小任务,排好序,持久化
解决什么:任务"搭数据库 + 写 API + 加测试",Agent 列完清单就开始写 API,写到一半发现没表、回头补,加测试时接口签名又变了。
金句 :"盖房子不能先盖屋顶再打地基。" 任务之间有先后,依赖应构成有向无环图(DAG) 。而 s05 的 TodoWrite 是当前会话内存里的执行清单。
另一句 :"TODO 列表不是任务系统。"
怎么做 :每个任务一个 JSON 文件,落在 .tasks/{id}.json。
python
@dataclass
class Task:
id: str # "task_{timestamp}_{random_hex(4)}"
subject: str
description: str
status: str # pending | in_progress | completed
owner: str | None # Agent 名,多 Agent 防重复认领
blockedBy: list[str] # 上游任务 ID 列表
can_start(task_id):遍历blockedBy,任一依赖文件不存在或状态不是completed即 False。缺失依赖视为 blocked------避免引用错 ID 时崩溃。claim_task:先查status != "pending"→ 拒绝(已被认领);再查can_start→ 返回"Blocked by: [...];通过才设owner转in_progress。- 状态机 :
pending --claim--> in_progress --complete--> completed,没有回退路径。(队友异常终止时的状态回滚,是 s15 之后才补的。)
| TodoWrite (s05) | Task System (s12) | |
|---|---|---|
| 定位 | 当前任务执行清单 | 可恢复的任务系统 |
| 存储 | 进程内 / 会话状态 | .tasks/{id}.json |
| 依赖 | 无 | blockedBy / blocks 依赖图 |
| 生命周期 | 当前会话 | 跨会话保留 |
| 分工 | 不认领 | owner / claim |
📌 真实 CC 差在哪
- TaskRecord 是 9 个字段 (教学版 5 个):多出
activeForm(进行时态,spinner 显示)、blocks(下游)、metadata。 - 不是"TodoWrite 升级版",是两个并列系统 ,靠
isTodoV2Enabled()切换。 - 双重锁防并发抢认领 (
tasks.ts:541-612):proper-lockfile锁{taskId}.json,最多重试 30 次 、指数退避 5--100ms ;锁内重新读取防 TOCTOU。 - 🔑 关键差异 :真实
claimTask只设 owner 不改 status ,状态更新交给TaskUpdate;教学版把 claim 和开工合成一步。 - 四个工具均
isConcurrencySafe: true+shouldDefer: true(schema 不进初始 prompt,需 ToolSearch 后才可见)。
s13 · Background Tasks ------ 慢操作丢后台,Agent 继续处理
教程类比 :"你用过洗衣机吗?把衣服扔进去,按启动,然后去干别的......你不会站在洗衣机前面干等 30 分钟。"
解决什么 :pip install torch 十分钟、npm run build 三分钟。同步执行时 Agent 干等十分钟什么都不做------而 LLM 按 token 计费,空转就是烧钱。
怎么做:
python
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
return True # 模型显式请求优先
return is_slow_operation(tool_name, tool_input) # 关键词启发式(仅 bash)
关键词白名单:install/build/test/deploy/compile/docker build/pip install/npm install/cargo build/pytest/make,注释写明 "> 30s"。
- 启动 :
threading.Thread(daemon=True),全局background_tasks/background_results配threading.Lock(),返回递增 IDbg_{counter:04d}。daemon=True保证主进程退出时线程跟着死。 - 回收 :扫出
status == "completed"的 ID,pop掉并格式化成<task_notification>XML(含<task_id>/<status>/<command>/<summary>,summary 截前 200 字符)。 - 🔑 不复用原
tool_use_id:一个tool_use只能配对一个tool_result,后台完成是独立事件。 - 循环集成 :慢操作立即回占位
tool_result: "[Background task bg_0001 started]...",本轮结束时<task_notification>与同期 tool_result 合并成一条 user 消息的多个 block。
📌 真实 CC 差在哪
- 没有真正的线程 :Node/Bun 单线程事件循环,"后台" = 不 await ,
ShellCommand.background(taskId)把 stdout/stderr 重定向到文件让子进程独立跑。 pendingToolUseSummary:Haiku 后台生成摘要(query.ts:1411-1482),prompt 要求 "think git-commit-subject, not sentence",过去时态、约 30 字符。Haiku ~1s 在主模型流式 5--30s 期间跑完,供 UI 展示进度。- 7 种后台任务类型 (
Task.ts:7-13):local_bash、local_agent、remote_agent、in_process_teammate、local_workflow、monitor_mcp、dream。 - 停滞看门狗 :45 秒输出无增长后检测
(y/n)等交互式提示,防后台任务卡在无人应答的对话框。 - 并发限制 :前台
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY默认 10;后台 bash 无硬限制。
s14 · Cron Scheduler ------ 按时间表生产工作,调度与执行解耦
金句 :"闹钟不需要你盯着它才会响。"
解决什么:s13 解决了慢操作不阻塞,但所有操作仍由人手动推------你说一句、Agent 动一下。"每天早上 9 点跑测试"不该每次靠人。
怎么做 :四层解耦模型。
#mermaid-svg-CFYF2f8vDSYzjnut{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CFYF2f8vDSYzjnut .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CFYF2f8vDSYzjnut .error-icon{fill:#552222;}#mermaid-svg-CFYF2f8vDSYzjnut .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CFYF2f8vDSYzjnut .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CFYF2f8vDSYzjnut .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CFYF2f8vDSYzjnut .marker.cross{stroke:#333333;}#mermaid-svg-CFYF2f8vDSYzjnut svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CFYF2f8vDSYzjnut p{margin:0;}#mermaid-svg-CFYF2f8vDSYzjnut .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CFYF2f8vDSYzjnut .cluster-label text{fill:#333;}#mermaid-svg-CFYF2f8vDSYzjnut .cluster-label span{color:#333;}#mermaid-svg-CFYF2f8vDSYzjnut .cluster-label span p{background-color:transparent;}#mermaid-svg-CFYF2f8vDSYzjnut .label text,#mermaid-svg-CFYF2f8vDSYzjnut span{fill:#333;color:#333;}#mermaid-svg-CFYF2f8vDSYzjnut .node rect,#mermaid-svg-CFYF2f8vDSYzjnut .node circle,#mermaid-svg-CFYF2f8vDSYzjnut .node ellipse,#mermaid-svg-CFYF2f8vDSYzjnut .node polygon,#mermaid-svg-CFYF2f8vDSYzjnut .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CFYF2f8vDSYzjnut .rough-node .label text,#mermaid-svg-CFYF2f8vDSYzjnut .node .label text,#mermaid-svg-CFYF2f8vDSYzjnut .image-shape .label,#mermaid-svg-CFYF2f8vDSYzjnut .icon-shape .label{text-anchor:middle;}#mermaid-svg-CFYF2f8vDSYzjnut .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CFYF2f8vDSYzjnut .rough-node .label,#mermaid-svg-CFYF2f8vDSYzjnut .node .label,#mermaid-svg-CFYF2f8vDSYzjnut .image-shape .label,#mermaid-svg-CFYF2f8vDSYzjnut .icon-shape .label{text-align:center;}#mermaid-svg-CFYF2f8vDSYzjnut .node.clickable{cursor:pointer;}#mermaid-svg-CFYF2f8vDSYzjnut .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CFYF2f8vDSYzjnut .arrowheadPath{fill:#333333;}#mermaid-svg-CFYF2f8vDSYzjnut .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CFYF2f8vDSYzjnut .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CFYF2f8vDSYzjnut .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CFYF2f8vDSYzjnut .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CFYF2f8vDSYzjnut .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CFYF2f8vDSYzjnut .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CFYF2f8vDSYzjnut .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CFYF2f8vDSYzjnut .cluster text{fill:#333;}#mermaid-svg-CFYF2f8vDSYzjnut .cluster span{color:#333;}#mermaid-svg-CFYF2f8vDSYzjnut div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CFYF2f8vDSYzjnut .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CFYF2f8vDSYzjnut rect.text{fill:none;stroke-width:0;}#mermaid-svg-CFYF2f8vDSYzjnut .icon-shape,#mermaid-svg-CFYF2f8vDSYzjnut .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CFYF2f8vDSYzjnut .icon-shape p,#mermaid-svg-CFYF2f8vDSYzjnut .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CFYF2f8vDSYzjnut .icon-shape .label rect,#mermaid-svg-CFYF2f8vDSYzjnut .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CFYF2f8vDSYzjnut .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CFYF2f8vDSYzjnut .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CFYF2f8vDSYzjnut :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
Agent 空闲
跑完释放锁
① Scheduler
daemon 线程
每秒 sleep(1) 轮询
cron_matches?
② cron_queue
(队列)
③ Queue Processor
每 0.2s 检查
队列非空 且 拿到 agent_lock
④ Consumer
agent_loop
consume_cron_queue()
注入 Scheduled prompt
python
@dataclass
class CronJob:
id: str
cron: str # "0 9 * * *" 五段式
prompt: str # 触发时注入的消息
recurring: bool # True 周期性 / False 一次性(触发后自动 pop)
durable: bool # True 写 .scheduled_tasks.json 跨会话
三个容易写错的细节:
cron_matches的 DOM/DOW 语义 :分钟、小时、月必须全匹配;日(DOM)与星期(DOW)同时被约束时取 OR(Unix cron 的老规则)。- 星期换算 :Python
dt.weekday()周一 = 0,cron 周日 = 0,需dow_val = (dt.weekday() + 1) % 7。 - 防重复触发 :
minute_marker = now.strftime("%Y-%m-%d %H:%M")带日期,同分钟只发一次、跨天也不会漏。
⚠️ 重要前提 :调度器跑在 Agent 进程内,进程关了调度就停。Durable 只是定义跨重启保留,下次启动才发现"该补触发了"。要真正脱离进程请用系统 crontab / systemd timer。
📌 真实 CC 差在哪
- 双重门控 :编译时
feature('AGENT_TRIGGERS')+ 运行时 GrowthBooktengu_kairos_cron,另有CLAUDE_CODE_DISABLE_CRON本地覆盖。 - 防惊群抖动 :重复任务延迟最多达周期的 10% (上限 15 分钟,基于任务 ID 的确定性哈希);一次性任务若落在
:00或:30,最多提前 90 秒。 - 自动过期 :重复任务 7 天后过期(可配,上限 30 天),末次触发后自动删除。
- 作业上限
MAX_JOBS = 50(CronCreateTool.ts:25)。 - QoS 降级 :cron 触发的请求标记
workload: WORKLOAD_CRON------API 在容量紧张时对 cron 请求降优先级。 .scheduled_tasks.lock防止同项目多 session 重复触发;支持*、*/N、N-M、N-M/S、N,M,...,不支持L、W、?。
第四部分 · 让 Agent 组队干活(s15--s19)
这一层回答:一个 Agent 的注意力覆盖不了大项目,怎么让多个 Agent 协作而不打架。
s15 · Agent Teams ------ 一个搞不定,组队来
解决什么 :"重构整个后端"横跨认证、数据库、API 路由、测试------一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了 。而 s06 的子 Agent 是临时工,叫来干一件事就走了,做不了需要持续通信的协作。
怎么做:三样新东西。
python
class MessageBus:
def send(self, from_agent, to_agent, content, msg_type="message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type, "ts": time.time()}
with open(MAILBOX_DIR / f"{to_agent}.jsonl", "a") as f: # 文件收件箱
f.write(json.dumps(msg) + "\n")
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
msgs = [json.loads(l) for l in inbox.read_text().splitlines()]
inbox.unlink() # 消费式:读完删除
return msgs
- MessageBus :每个 Agent(含 Lead)一个
.mailboxes/{name}.jsonl。发送 = 往对方文件 append 一行 JSON;读取 = 读全文后unlink(),消费式删除。选文件而非内存队列的理由:直观、跨线程可观察。 spawn_teammate_thread(name, role, prompt):队友跑在自己的 daemon 线程里,有独立 system prompt("You are '{name}', a {role}.")、独立 messages、精简工具集(bash/read/write/send_message 共 4 个) ,每轮先把 inbox 包成<inbox>{json}</inbox>注入,教学版硬限 10 轮,跑完自动汇报。- Lead 侧注入 :主循环每轮结束读自己的 inbox,截断到 200 字符拼成
[Inbox]\nFrom {from}: {content}追加进 history。
| s06 子 Agent | s15 队友 | |
|---|---|---|
| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,CC 用 idle loop) |
| 通信 | 只回传结论 | 异步收件箱,随时互发 |
| 上下文 | 完全隔离 | 通过消息共享 |
| 数量 | 1 主 + 偶尔子 Agent | 1 Lead + N 队友 |
📌 真实 CC 差在哪
- 没有中央总线,就是文件系统 :路径
~/.claude/teams/{teamName}/inboxes/{agentName}.json(注意是 JSON 数组不是 JSONL ),写入用proper-lockfile,最多重试 10 次,append 语义是读→追加→写回。 - 15 种结构化消息类型 :
idle_notification、permission_request/permission_response、plan_approval_request/plan_approval_response、shutdown_request/shutdown_approved/shutdown_rejected、task_assignment、teammate_terminated等。文本消息包在<teammate-message>XML 标签里交付。 - 权限冒泡 (教学版省略):队友遇审批 →
permission_request→ Lead 的useInboxPoller(每 1 秒 )→ UI 弹窗 → 用户审批 →permission_response→ 队友useSwarmPermissionPoller(每 500ms)收到后继续。 - 队友不能嵌套 :
AgentTool.tsx:273明令禁止 "teammates spawning other teammates"。 - ⚠️ 教学版
read_inbox存在 read + unlink 竞态(多线程同读会丢消息)。
s16 · Team Protocols ------ 队友之间要有约定
解决什么 :s15 的协调是松散文本消息。两个场景暴露缺陷:关机 时直接杀线程,Alice 写了一半的文件留在磁盘;计划审批 时 Bob 要重构认证模块,Lead 完全不知情。两个场景结构相同------一方发请求、另一方回复,靠同一个 ID 关联。
怎么做 :把"发消息---收回复"升级成带 request_id 关联、有状态机追踪的请求-响应协议。
python
def match_response(response_type, request_id, approve):
state = pending_requests.get(request_id)
if not state: return
if state.type == "shutdown" and response_type != "shutdown_response": return # 类型校验
if state.type == "plan_approval" and response_type != "plan_approval_response": return
if state.status != "pending": return # 幂等:已决,跳过重复响应
state.status = "approved" if approve else "rejected"
金句 :「
request_id是贯穿全链路的关联键,请求带着它出去,回复带着它回来。」「一个shutdown_response不会意外 approve 一个plan_approval请求。」
配套改动:队友生命周期改为 idle loop ------LLM 返回非 tool_use 后不退出,每秒轮询 inbox;consume_lead_inbox() 统一消费函数让 check_inbox 工具和主循环共用,避免"消息被读走但状态没更新"。
📌 真实 CC 差在哪
- 关机是三向通信 (
teammateMailbox.ts:720-763):shutdown_request→shutdown_approved/shutdown_rejected→ 系统广播teammate_terminated,随后自动清理 pane、unassign 任务、从 team config 移除成员。教学版合并成单一shutdown_response+approve字段。 - 计划审批 由
ExitPlanModeV2Tool.ts:263-312在 plan-mode-required 队友退出 plan mode 时产生;响应时支持同时设permissionMode("批准但以 plan mode 运行")和feedback供队友重提交。 - 消息是带 Zod schema 验证 的 JSON。字段名还不统一------permission 用
request_id,shutdown/plan approval 用requestId。 - CC 有强制 permission gating,教学版只演示消息流、不做执行拦截。
s17 · Autonomous Agents ------ 自己看板,自己认领
解决什么 :s16 里每个队友都等 Lead 派活,看板上 10 个未认领任务就要 assign 10 次,无法横向扩展。
怎么做 :生命周期从两阶段变三阶段。
| 阶段 | 行为 |
|---|---|
| WORK | inbox → LLM → 工具循环,stop_reason != tool_use 退出 |
| IDLE | 每 5s 轮询 inbox + 任务板(IDLE_POLL_INTERVAL=5),60s 超时(IDLE_TIMEOUT=60,即 12 次) |
| SHUTDOWN | 发 summary 退出 |
python
def scan_unclaimed_tasks() -> list[dict]:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])): # 依赖判断:"blockedBy 中没有未完成的"
unclaimed.append(task)
return unclaimed
四个关键设计:
- 优先级:inbox 优先于任务板 (可能含
shutdown_request)。IDLE 阶段收到shutdown_request直接回复退出,不等下一轮 WORK------否则关机会被饿死。 can_start的语义是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。claim_task拒绝非 pending 或已有 owner 的任务,调用方检查"Claimed" in result才算成功。- 身份重注入 :
if len(messages) <= 3说明发生过autoCompact(s08),重新插入<identity>You are '{name}', role: {role}...</identity>。这个细节很容易漏------压缩会把队友的身份认知一起压掉。
📌 真实 CC 差在哪
教学版的 idle_poll 其实是四个机制合并 的简化:① sendIdleNotification()(inProcessRunner.ts:569-589)向 Lead 发空闲通知;② waitForNextPromptOrShutdown()(:689-868)是 500ms 轮询循环 ,shutdown_request 优先处理;③ useTaskListWatcher 用 fs.watch() 监听 .claude/tasks/,1 秒 debounce ;④ 轮询内部主动调 tryClaimNextTask()(:624-657)。另外 CC 没有固定超时(Lead 手动 shutdown),且 context compaction 天然保留 system prompt,无需重注入。
s18 · Worktree Isolation ------ 各干各的目录,互不干扰
解决什么 :s15--s17 解决了"谁干什么"和"怎么通信",但所有队友共享同一 WORKDIR------Alice 重构认证模块、Bob 重构 UI 登录页,都 write_file("config.py"),互相覆盖,且分不清改动是谁的、无法干净回滚。
怎么做 :用 git worktree 给每个任务一个独立目录 + 独立分支。
python
def bind_task_to_worktree(task_id: str, worktree_name: str):
task = load_task(task_id)
task.worktree = worktree_name # 只写 worktree 字段
save_task(task) # 状态保持 pending,等队友 claim 时才推进
为什么要"只写字段不改状态" :这样 Lead 可以提前批量建好任务 + worktree ,队友 idle 时自动认领才推进 in_progress。
- 命名校验 :
validate_worktree_name只允许[A-Za-z0-9._-]{1,64},拒绝路径穿越。 - 创建 :
git worktree add <path> -b wt/<name> HEAD,成功才写日志。 - 收尾二选一 :
keep_worktree(留分支待人工 review 合并)或remove_worktree(discard_changes=False)------有改动时默认拒绝 (统计 files/commits,>0 即拒),需显式discard_changes=true;删除后git branch -D wt/<name>。 - 生命周期事件写入
.worktrees/events.jsonl供审计。
金句 :「Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝。」
📌 真实 CC 差在哪
- 🔑 CC 没有 task-worktree 绑定 。worktree 状态由
PersistedWorktreeSession(worktree.ts:756-768)管理,字段originalCwd/worktreePath/worktreeName/worktreeBranch/originalBranch/originalHeadCommit/sessionId,没有 taskId 。CC 把两者当独立系统,靠 Agent 自己理解上下文关联。 EnterWorktree是进程级目录变更 (EnterWorktreeTool.ts:92-97):创建后立刻process.chdir(worktreePath)+setCwd()+saveWorktreeState(),不是 prompt 提醒。- 命名:真实路径
.claude/worktrees/、分支worktree-{slug}(斜杠换+);创建用git worktree add -B,且优先基于origin/<defaultBranch>而非 HEAD。
s19 · MCP Tools ------ 外接工具,标准协议
解决什么 :s01--s18 所有工具都是手写的------bash/read/write/task/worktree,输入验证、执行、错误处理全靠一行行写。要接 Jira、自建部署系统、Notion 三个服务,不想重写三套工具代码。
怎么做 :外部服务只要实现 MCP 协议,Agent 就能发现并调用,不管它用什么语言写的。
python
def assemble_tool_pool() -> tuple[list[dict], dict]:
tools = list(BUILTIN_TOOLS); handlers = dict(BUILTIN_HANDLERS)
for server_name, mcp_client in mcp_clients.items():
safe_server = normalize_mcp_name(server_name)
for tool_def in mcp_client.tools:
prefixed = f"mcp__{safe_server}__{normalize_mcp_name(tool_def['name'])}"
tools.append(...)
handlers[prefixed] = (lambda *, c=mcp_client, t=tool_def["name"], **kw:
c.call_tool(t, kw)) # 闭包默认参数捕获
return tools, handlers
- 命名 :
mcp__{server}__{tool}前缀避免跨 server 冲突;normalize_mcp_name用re.compile(r'[^a-zA-Z0-9_-]')把非法字符全替换成_。 - 🔑 重要取舍:s10 起的 prompt cache 被去掉了 。因为
connect_mcp后工具池变了,缓存里的工具列表是旧的,模型调不到新工具------所以每轮都要assemble_tool_pool()+assemble_system_prompt()重建。代价只是多花序列化时间。 - 另外两个简化:MCP 工具 description 只用文本标注
(readOnly)/(destructive),不做权限拦截 ;MCP 工具只有 Lead 可用(真实 CC 中子 agent 继承父级 MCP 配置)。
📌 真实 CC 差在哪
- 6 种 transport (
types.ts:23-25):stdio / sse / http / ws / sse-ide / sdk,连接时本地批量 3 个、远程批量 20 个并发。 - 🔑
assembleToolPool()(tools.ts:345-364)用uniqBy([...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)], 'name')------内置与 MCP 分开排序 。因为claude_code_system_cache_policy在最后一个内置工具之后放全局缓存断点,混排会破坏缓存。 - MCP 工具有独立权限系统 ,
checkPermissions()按工具声明的 readOnly/destructive 决定是否需用户确认。 - 配置优先级 从低到高:
claude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json;企业managed-mcp.json存在时完全排除其他。 - Channel 反向通知 (
channelNotification.ts):server 声明capabilities.experimental['claude/channel']后可主动推送,消息包进<channel source="serverName">,Agent 被 SleepTool 1 秒内唤醒。 - OAuth 2.0 + PKCE :RFC 8414/9728 发现元数据、本地回调、
getSecureStorage()持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器)、过期前 5 分钟自动刷新。
终章 · s20 一个完整 Agent Turn
Harness 层:综合 ------ 把前 19 章的机制放回同一个可运行系统。 1708 LOC,32 个工具。
核心主张 :s20 不再发明新机制。难点也不是堆功能,而是看清每个机制挂在循环的哪个位置------"把所有组件归位"。
一个完整 turn 的触发顺序
#mermaid-svg-aVeFrjhAG2K3wnxQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aVeFrjhAG2K3wnxQ .error-icon{fill:#552222;}#mermaid-svg-aVeFrjhAG2K3wnxQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aVeFrjhAG2K3wnxQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .marker.cross{stroke:#333333;}#mermaid-svg-aVeFrjhAG2K3wnxQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aVeFrjhAG2K3wnxQ p{margin:0;}#mermaid-svg-aVeFrjhAG2K3wnxQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster-label text{fill:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster-label span{color:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster-label span p{background-color:transparent;}#mermaid-svg-aVeFrjhAG2K3wnxQ .label text,#mermaid-svg-aVeFrjhAG2K3wnxQ span{fill:#333;color:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .node rect,#mermaid-svg-aVeFrjhAG2K3wnxQ .node circle,#mermaid-svg-aVeFrjhAG2K3wnxQ .node ellipse,#mermaid-svg-aVeFrjhAG2K3wnxQ .node polygon,#mermaid-svg-aVeFrjhAG2K3wnxQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .rough-node .label text,#mermaid-svg-aVeFrjhAG2K3wnxQ .node .label text,#mermaid-svg-aVeFrjhAG2K3wnxQ .image-shape .label,#mermaid-svg-aVeFrjhAG2K3wnxQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-aVeFrjhAG2K3wnxQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .rough-node .label,#mermaid-svg-aVeFrjhAG2K3wnxQ .node .label,#mermaid-svg-aVeFrjhAG2K3wnxQ .image-shape .label,#mermaid-svg-aVeFrjhAG2K3wnxQ .icon-shape .label{text-align:center;}#mermaid-svg-aVeFrjhAG2K3wnxQ .node.clickable{cursor:pointer;}#mermaid-svg-aVeFrjhAG2K3wnxQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .arrowheadPath{fill:#333333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aVeFrjhAG2K3wnxQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aVeFrjhAG2K3wnxQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aVeFrjhAG2K3wnxQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster text{fill:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ .cluster span{color:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aVeFrjhAG2K3wnxQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aVeFrjhAG2K3wnxQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-aVeFrjhAG2K3wnxQ .icon-shape,#mermaid-svg-aVeFrjhAG2K3wnxQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aVeFrjhAG2K3wnxQ .icon-shape p,#mermaid-svg-aVeFrjhAG2K3wnxQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aVeFrjhAG2K3wnxQ .icon-shape .label rect,#mermaid-svg-aVeFrjhAG2K3wnxQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aVeFrjhAG2K3wnxQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aVeFrjhAG2K3wnxQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aVeFrjhAG2K3wnxQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
👤 用户输入
UserPromptSubmit hooks
cron queue + background notifications 注入
compaction pipeline
budget → snip → micro → history
assemble_system_prompt
memory + skills 目录 + MCP 状态
🤖 LLM 调用
(error recovery 包在外面)
content 里有
tool_use block?
Stop hooks
✅ 返回
PreToolUse hooks + permission
assemble_tool_pool 分发
内置 / MCP / background
PostToolUse hooks
tool_result / task_notification
回 messages
⚠️ 再次强调:CC 源码不信任
stop_reason == "tool_use",而以实际出现的 tool_use block 作为是否继续工具轮的信号。
各机制在循环中的位置(原教程总表)
| 位置 | 组件 | 作用 |
|---|---|---|
| 用户输入前后 | UserPromptSubmit hooks |
记录、注入、审计用户输入 |
| LLM 前 | cron queue | 把定时触发的 prompt 注入 messages |
| LLM 前 | background notifications | 后台任务完成后以 <task_notification> 注入 |
| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |
| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |
| LLM 调用 | error recovery | 429/529 重试,max_tokens 升级,prompt too long 触发 reactive compact |
| 工具执行前 | PreToolUse hooks + permission |
拦截危险命令、写越界、破坏性 MCP 工具 |
| 工具分发 | assemble_tool_pool |
组装内置工具和 MCP 动态工具 |
| 工具执行时 | background dispatch | 慢 bash 放 daemon thread,主循环先返回占位结果 |
| 工具执行后 | PostToolUse hooks |
大输出告警、日志等后处理 |
| 返回循环 | tool_result | 每个 tool_use 对应一个 tool_result,再回到下一轮 |
| 停止时 | Stop hooks |
统计、清理、审计 |
按层归类:输入层 → 上下文层 → 模型调用层 → 执行层 → 回路层 → 终止层。
五个"成对出现"的设计,值得单独记住
| 对比项 | A | B | 分野 |
|---|---|---|---|
| 计划 | todo_write(会话内内存态) |
task graph(.tasks/*.json) |
防单 Agent 漂移 vs 跨会话可依赖可认领 |
| 委派 | task(一次性 subagent,独立 messages,只回摘要) |
spawn_teammate(持久线程,MessageBus + idle 轮询) |
上下文隔离 vs 长期并行协作 |
| 隔离 | 独立 messages[] |
git worktree | 上下文 vs 文件系统 |
| 压缩 | 便宜的(budget/snip/micro,0 API 调用) | 贵的(LLM 摘要) | 先跑便宜的 |
| 扩展 | 改循环 | 挂 hook | 后者才是对的 |
全书终态代码(也是 s01 的原形)
python
while True:
response = LLM(messages, tools)
if not has_tool_use(response.content):
return
results = execute_tools(response.content)
messages.append(tool_results)
金句 :「Claude Code 的复杂性不是"另一个 agent 大脑",而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。」
「这就是全书的终点:机制很多,循环一个。」------ 结束亦是开始。
附录 A:常量速查表
教学版关键常量
| 常量 | 值 | 章节 | 含义 |
|---|---|---|---|
max_tokens |
8000 | s01 | 初始输出上限 |
ESCALATED_MAX_TOKENS |
64000 | s11 | 截断后升级值(8 倍),只有一次机会 |
MAX_RECOVERY_RETRIES |
3 | s11 | 续写最多 3 次 |
MAX_RETRIES / BASE_DELAY_MS |
10 / 500 | s11 | 429/529 指数退避,+25% 抖动,封顶 32s |
MAX_529_RETRIES |
3 | s11 | 连续 3 次过载才切 fallback model |
KEEP_RECENT_TOOL_RESULTS |
3 | s08 | micro compact 保留最近几条 tool_result |
| micro compact 阈值 | 120 字符 | s08 | 超过才替换成占位符 |
| snip 阈值 | 50 条消息 | s08 | 保留头 3 + 尾 47 |
tool_result_budget |
200000 字符 | s08 | 落盘阈值 |
| compact 失败熔断 | 3 次 | s08 | 连续失败就放弃 |
reactive_compact 保留 |
尾 5 条 / 重试 1 次 | s08/s11 | PTL 应急 |
CONSOLIDATE_THRESHOLD |
10 | s09 | 记忆文件数达标才整理 |
| memory side-query | max_tokens=200,最多 5 条 |
s09 | 失败降级关键词匹配 |
| 子 Agent 轮数 | 30 | s06 | safety limit |
| 队友轮数 | 10 | s15 | 教学版硬限 |
IDLE_POLL_INTERVAL / IDLE_TIMEOUT |
5s / 60s | s17 | idle 轮询间隔与超时(12 次) |
| cron 轮询 | 1s(scheduler)/ 0.2s(processor) | s14 | --- |
| 后台 summary 截断 | 200 字符 | s13 | <task_notification> 里的 summary |
真实 CC 关键常量
| 常量 | 值 | 出处 |
|---|---|---|
AUTOCOMPACT_BUFFER_TOKENS |
13,000 | autoCompact.ts:62 |
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES |
3 | autoCompact.ts:70 |
MAX_OUTPUT_TOKENS_FOR_SUMMARY |
20,000 | autoCompact.ts:30 |
POST_COMPACT_TOKEN_BUDGET |
50,000 | compact.ts:123 |
POST_COMPACT_MAX_FILES_TO_RESTORE |
5 | compact.ts:122 |
POST_COMPACT_MAX_TOKENS_PER_FILE |
5,000 | compact.ts:124 |
| 时间型 micro compact 间隔 | 60 分钟 | timeBasedMCConfig.ts |
| 退避公式 | min(500×2^(n-1), 32000) + 0~25% 抖动 |
withRetry.ts:530-548 |
| 记忆注入预算 | 每文件 ≤200 行 / 4096 字节;session 总 60KB;MEMORY.md ≤200 行 / 25KB |
attachments.ts:269-288、memdir.ts:34-38 |
| 记忆扫描上限 | 最多 200 个,按 mtime 降序 | memoryScan.ts:35-94 |
| Dream 门控 | ≥24h / ≥5 transcript / 锁 1h 过期 | autoDream.ts:130-190 |
sessionMemoryCompact 阈值 |
≥10K token、≥5 条文本消息、≤40K token | sessionMemoryCompact.ts:56-61 |
MAX_JOBS(cron) |
50 | CronCreateTool.ts:25 |
| cron 抖动 | 周期 10%,上限 15 分钟 | cronScheduler.ts |
| cron 自动过期 | 7 天(上限 30 天) | cronTasks.ts |
| task 锁重试 | 30 次,指数退避 5--100ms | tasks.ts:541-612 |
| mailbox 写入重试 | 10 次 | teammateMailbox.ts |
| 权限轮询 | Lead 1s / 队友 500ms | useInboxPoller.ts、useSwarmPermissionPoller.ts |
| 后台看门狗 | 45 秒无输出增长 | LocalShellTask.tsx:59-98 |
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY |
10 | --- |
| skills 目录预算 | 上下文窗口 1%,上限 8000 字符 | SkillTool.ts |
| system prompt 规模 | 20--30KB(标准)/ ~150 字符(CLAUDE_CODE_SIMPLE) |
--- |
附录 B:20 章总表
| # | 机制 | 一句话 | LOC | 工具数 |
|---|---|---|---|---|
| s01 | Agent Loop | 一个工具 + 一个循环 = 一个 Agent | 102 | 1 |
| s02 | Tool Use | 加一个工具只加一行,循环不动 | 135 | 5 |
| s03 | Permission | 安全不能靠信任模型,要靠代码 | 180 | 5 |
| s04 | Hooks | 挂在循环上,不写进循环里 | 232 | 5 |
| s05 | TodoWrite | 它不增加执行能力,增加的是规划能力 | 236 | 6 |
| s06 | Subagent | 独立 messages,只回传结论,禁止递归 | 304 | 7 |
| s07 | Skills | 目录 ~100 tokens 常驻,正文 ~2000 tokens 按需 | 335 | 8 |
| s08 | Context Compact | 便宜的先跑,贵的后跑;顺序不能换 | 414 | 9 |
| s09 | Memory | 一层不参与压缩、跨会话保留的存储 | 528 | 6 |
| s10 | System Prompt | 运行时按真实状态组装,不是硬编码 | 166 | 3 |
| s11 | Error Recovery | 升级 token / 压缩上下文 / 切换模型 | 287 | 3 |
| s12 | Task System | 盖房子不能先盖屋顶再打地基 | 297 | 8 |
| s13 | Background Tasks | 洗衣机转着,你去干别的 | 379 | 8 |
| s14 | Cron Scheduler | 闹钟不需要你盯着它才会响 | 645 | 11 |
| s15 | Agent Teams | 一个 Agent 的注意力覆盖不了所有模块 | 745 | 14 |
| s16 | Team Protocols | request_id 贯穿全链路 |
709 | 15 |
| s17 | Autonomous Agents | 自己看板,自己认领 | 648 | 15 |
| s18 | Worktree Isolation | 任务管目标,worktree 管目录 | 802 | 18 |
| s19 | MCP Tools | 外接工具,标准协议 | 835 | 23 |
| s20 | Comprehensive Agent Turn | 机制很多,循环一个 | 1708 | 32 |
写在最后:三条最该带走的认知
-
Agent 的复杂度不在模型,在 harness。 模型只负责"下一步做什么",剩下的执行、权限、记忆、压缩、恢复、协作,全是 harness 的工程活。s01 的 30 行循环 + 19 章的挂载点,就是全部。
-
上下文是一种需要被管理的稀缺资源。 s05(计划)s06(隔离)s07(按需加载)s08(压缩)s09(记忆)s10(组装)这六章,本质上都在回答同一个问题:有限的窗口里,放什么、丢什么、什么时候丢。
-
知道什么时候该放弃,和知道什么时候该重试一样重要。 s11 里"升级只有一次机会""压缩过了还是超限就退出""连续 3 次 continuation 且 token 增量 < 500 就停"------这些熔断条件才是教学 demo 和线上系统真正的分水岭。
如果这篇笔记对你有帮助,欢迎点赞收藏。有任何错漏,欢迎在评论区指出。