学习《Harness Engineering:Claude Code 设计指南》总结,原文地址:https://harness-books.agentway.dev/book1-claude-code/preface.html

导读:先有规矩,再谈聪明

这些年我们见过太多"会写代码的模型",但把模型放进终端文件系统和团队协作中,安全地变成生产力是另一回事。一个只会输出文本的模型犯错,顶多造成理解成本。一个能执行命令修改仓库的模型犯错,留下的就是混乱的 Git 历史、损坏的配置文件和谁也说不清的维护债务。

系统可靠,不在它会不会说,而在它出了岔子以后,谁来收拾残局。

本教程将带你从零开始,模仿 Cloud Code 的源码设计思想,搭建一套可落地的 Harness Engineering 体系。我们不谈“如何写更好地 prompt”,而是谈如何构建控制面、主循环、权限系统、上下文预算、恢复路径和验证机制,把不可靠的模型收束进可持续运行的工程秩序。

第一章 把 Prompt 当控制面,别当人格装修

很多团队把 system prompt 视作“人设文本”:你是一个高级工程师,你擅长 React…… 这在聊天机器人场景中够用,但对一个能碰终端、读文件、跨轮执行任务的 Agent 来说,远远不够。

设计原则:Prompt 不是人格,是运行时协议。他应该像一套分层拼装的“宪法”,定义模型能做什么、不能做什么、何时停止、谁来兜底。

1.1 分层而非单块

Claude Code 的 system prompt 不是一大段文字,而是由多个职责不同的区块拼装而成:

  • 身份与总任务:说明“我是交互式软件工程代理”。

  • 系统级规则:告知工具调用会触发审批、用户拒绝后不可机械重试、上下文可能被压缩等。

  • 工程约束:不要乱猜 URL、不要把验证说成完成、不要越权优化、不要在没有必要时制造抽象。

实现骨架:

// 骨架: getSystemPrompt()
sections = [
  identityAndTask,      // 身份
  systemConstraints,    // 系统级规则:权限、中断、压缩
  engineeringDiscipline,// 工程纪律:不乱增需求,不掩盖失败
  dynamicReminders      // 动态注入:memory、语言、output style
]
return assemble(sections)

不变式(invariant):

assert prompt.layers ⊇ {default, project, custom, agent, append}
// 意思是:一个完整的 prompt 至少应该包含这些层:
// default:默认基础规则
// project:项目相关规则
// custom:用户或团队自定义规则
// agent:当前智能体角色规则
// append:最后追加的临时补充说明

你不能让一段“万用提示词”解决所有问题。分层让职责清晰,让团队能按项目、按角色注入规则而不互相覆盖。

1.2 定义严格的优先级

当 prompt 有多个来源时,必须提前规定“谁说了算”。

比如一个 AI 工具有很多层规则:

  • 默认规则:系统自带的基础要求

  • 项目规则:某个项目里的约定

  • 自定义规则:用户或团队加的规则

  • Agent 专属规则:某个智能体自己的角色设定

  • override:最高优先级的临时覆盖规则

  • append:最后追加的补充说明

问题是:这些规则可能互相冲突。

比如默认规则说“不要自动提交代码”,项目规则说“改完自动提交”,Agent 规则又说“先问用户”。这时候不能靠“谁写在后面谁赢”,因为那样太脆弱,也容易被误改。这时候需要一个硬编码的优先级链:

// 骨架: buildEffectiveSystemPrompt()
sources = [override, coordinator, agent, custom, default]
base = first_present(sources)   // 找到最高优先级的有值来源,按照这个顺序找,谁先存在就用谁作为基础版本。
// 大致优先级是:override > coordinator > agent > custom > default 也就是:临时强制覆盖 > 协调器规则 > Agent 规则 > 用户/团队自定义 > 默认规则
if proactive_mode and agent:
    base = default + agent      // 特例:叠加而非完全替换,在某些主动模式下,不是让 agent 完全替换 default,而是把默认规则和 agent 规则叠加起来。这样既保留系统底线,又加入 agent 的能力设定。
return base + appendSystemPrompt // appendSystemPrompt 永远只是追加在最后,作为补充说明,不能替换基础规则。

不变式:

assert precedence(override) > precedence(default)
assert appendSystemPrompt never replaces base

翻译成人话就是:

  1. override 的优先级必须高于 default。

  1. appendSystemPrompt 只能追加,不能把原来的基础 prompt 顶掉。

这保证了制度不会被轻易推翻。团队规范可以注入,但不能冲掉系统最基础的安全约束。

1.3 Prompt 连接记忆与知识治理

Prompt 的职责还要扩展到“长期记忆如何形成”。你的 Agent 应该明确告诉模型:记忆是文件化的持久系统,MEMORY.md 只是索引,不要在里面堆正文;哪些信息不该保存,plan 和 task 记录不属于长期记忆。

骨架:在 prompt 中嵌入记忆保存规则:

Memory 使用规则:
- MEMORY.md 仅存放一行指针,具体内容写到独立文件。
- 保存前评估信息是否长期有效,避免把临时调试笔记当成真理。
- 计划(plan)和一次性任务不要记入 memory。

这样一来,Prompt 从“约束当前行为”升级为“约束未来知识的沉淀方式”。控制面才算完整。

检查清单:

  • 系统 prompt 是否拆分为身份、规则、纪律等区块?

  • 是否有明确的优先级链,并支持 append 而不被覆盖?

  • 是否将记忆保存规则写入了系统指令?

  • 是否将危险操作禁令(如不允许 git push --force)写成了硬规则而非柔和建议?

第二章 Query Loop:给代理装上心跳

普通的问答是“请求-响应”,但一个能调用工具、跨轮次执行任务的代理,需要一段持续的、有状态的执行过程。这就是Query Loop。代理系统的真正心跳。

设计原则:真正的 Agent 依赖一个可恢复的带状态循环,而非一次性 API 调用。

2.1 建立跨迭代状态对象

不要把工具结果、轮次数、压缩标记散落在局部变量里,必须把它们装进一个明确的状态对象,在循环的每次迭代间传递。

// 骨架: 状态定义
interface LoopState {
  messages: Message[];               // 对话历史
  toolUseContext: ToolUseContext;    // 待执行的工具
  autoCompactTracking: {             // 自动压缩追踪
    consecutiveFailures: number;
    hasAttemptedReactiveCompact: boolean;
  };
  maxOutputTokensRecoveryCount: number;
  turnCount: number;
  transition: TransitionReason;      // 本轮是如何结束的
}

不变式:

assert state.turnCount_{t+1} >= state.turnCount_t  // 状态单调递增
assert every emitted tool_use has a matching tool_result // 工具调用必须闭环

2.2 循环结构:治理输入,消费流,调度工具,处理恢复

Query Loop 每一轮的工作不是“调模型”,而是:

  1. 治理输入:截断过长的历史、应用 tool result 预算、进行 microcompact、处理 context collapse。

  2. 调用模型:以流式方式消费模型输出事件。

  3. 调度工具:如果模型返回 tool_use,则按并发安全规则执行。

  4. 处理恢复:遇到 prompt too longmax output tokens 时走恢复分支。

  5. 决定下一轮:是继续、停止、等待用户输入,还是进入 stop hooks。

实现骨架:

// 骨架: queryLoop()
state = initState({ messages, ... })
while not done(state):
    governInput(state)   // 裁剪、压缩、微压缩
    events = streamModel(state)
    for e in events:
        if e.is(tool_use): schedule(e)
        if e.is(api_error): return surface(e)
        if interrupted: drainToolsWithSyntheticResults(state); break
    state = advanceAndRecover(state)

重要: 调用模型仅仅是循环中的一段,不是循环本身。真正的核心竞争力在于循环的治理和恢复能力。

2.3 区分完成、失败、恢复、继续

停止条件不能只有一个。你要至少区分以下状态:

  • 正常结束但有 tool_use,需要 follow-up

  • 正常结束无 tool_use,进入 stop hooks

  • 被用户中断

  • 触发 prompt-too-long 恢复

  • 触发 max-output-tokens 恢复

  • stop hook 阻塞导致重进循环

  • API 错误直接返回

这种区分确保系统不会把“完成一轮对话”和“任务成功”混为一谈。

检查清单:

  • 是否存在明确的 while 循环管理多轮交互?

  • 是否有统一的状态对象承载 messages、恢复标记、轮次?

  • 调用模型前是否先执行了输入治理(裁剪、预算控制)?

  • 是否将模型输出视为事件流处理?

  • 是否区分了至少四种终止语义(完成/失败/恢复/继续)?

第三章 工具、权限与中断:别让模型直接碰世界

一旦模型开始调用工具,风险就从“说错话”变成了“做错事”。工具系统最重要的问题:谁来约束这些工具?

答案是把工具变成受管执行接口。权限是 Agent 的基本器官,不是附加功能。

3.1 工具调度:并发需要证明安全

模型可能一次请求多个工具调用。你不能假设它们都可以安全并发。例如,两个修改同一文件的命令并发执行会互相破坏。

// 骨架: 调度工具
toolCalls = partitionToolCalls(rawCalls, tool => isConcurrencySafe(tool))
for batch in toolCalls.concurrent:
    results = await Promise.all(batch.map(run))
    // 按原始顺序回放上下文修改,而不是谁先完成谁先改
    replayContextModifiersInOrder(results)
for tool in toolCalls.serial:
    await run(tool)

不变式:

assert ∀ tool_call t: scheduler.decides_concurrency(t) before exec(t)
assert context modifier ordering == original block ordering

并发是性能优化,因果秩序是生命线。

3.2 权限三态:allow / deny / ask

授权不能是简单的布尔值。你要允许系统表达“我需要确认”。

// 骨架: 权限决策
decision = hasPermissionsToUseTool(tool, input, context)
switch (decision):
    case allow: exec()
    case deny:  reject(reason)
    case ask:   routeTo(coordinator | worker | interactive approval)

不变式:

assert decision ∈ {allow, deny, ask}   // 三值不塌缩
assert ask never auto-escalates to allow // 不能自动升级
assert deny is sticky for this tool_use_id // 同一请求不可重试

这样设计,系统才能清晰表达责任边界。Bash、文件写入、网络请求等高风险工具默认应该进“ask”路径。

3.3 中断是一等语义

用户随时可能按 Esc 打断。这时的要求不是“停”,而是“账本必须平”。所有已经发出的 tool_use 都必须有 synthetic tool_result,防止历史记录残缺。

// 骨架: 流式中断处理
on abort:
    if streamingToolExecutor:
        remaining = await executor.getRemainingResults()
        yield synthetic results for remaining tool uses
    else:
        yieldMissingToolResultBlocks(state)

StreamingToolExecutor 要能区分取消原因:用户中断、并行兄弟失败、流式 fallback,并生成对应的错误消息。同时工具本身要声明 interruptBehavior:可以被取消 (cancel) 还是必须完成 (block)。

检查清单:

  • 工具调用是否经过统一调度器,而非直接执行?

  • 是否存在并发安全性检查机制?

  • 权限模型是否至少支持 allow/deny/ask 三态?

  • 高风险工具(Bash)是否有更严格的审查规则(如复合命令上限)?

  • 中断时是否会补齐所有未完成的 tool_use 结果,保证历史闭环?

第四章 上下文治理:把上下文当成预算来花

“信息越多,模型越聪明”是一个危险神话。上下文是昂贵的工作内存,必须当成预算来治理。

设计原则:上下文治理的目标不是“多”,而是“可继续工作”。

4.1 分层存储:规则、记忆、会话连续性分开

  • CLAUDE.md 体系:存放项目级、团队级、个人级长期指令。按目录距离决定优先级,靠近当前工作目录的规则后加载,可以覆盖上层规则。

  • MEMORY.md 体系:长期记忆。入口文件必须是索引(不超过200行或25KB),具体内容存为单独文件,避免入口膨胀。

  • Session Memory:短期连续性。使用固定模板(Current State, Task, Errors & Corrections, Worklog 等),每节有 token 预算,由模型自动更新,确保一轮过后还能续上。

4.2 自动压缩(Auto Compact)与预算

上下文窗口不是无限的。你要预留压缩空间,并设置安全阈值。

预算阈值设计:

  • 输出预留MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20000,压缩本身要消耗 token,不能在只剩一口气时才开始求生。

  • 警戒缓冲AUTOCOMPACT_BUFFER_TOKENS = 13000,在窗口用满前 13000 token 就触发自动压缩,给恢复留余量。

压缩实现骨架:

function compactConversation(messages):
    cleanMessages = stripImagesAndReinjectedAttachments(messages) // 丢掉对压缩无用的重物
    summary = await model.summarize(cleanMessages)
    // 重建工作语义底座
    rebuildState = {
        summary,
        recentFileAttachments, // 最近操作的文件
        planAttachment,        // 当前计划
        skillAttachments,      // 已激活技能的指令(截断保留)
        hookStates             // 恢复 hook 状态
    }
    return rebuildState

关键点: 压缩的目标不是写一段好看的总结,而是重建一个能继续干活的上下文。计划状态、技能约束、错误修正记录,这些比冗长的历史聊天重要得多。

4.3 Session Memory 的骨架

用一个结构化的文件来维持短期操作记忆,而不是硬读聊天记录:

# Current State: 正在实现用户登录模块的密码重置功能
# Task: 在 auth.ts 中添加 resetPassword 方法
# Errors & Corrections: 上次 bcrypt 导入失败,已用 import * as bcrypt 解决
# Worklog:
- 已创建 reset-password.ts
- 单元测试正在编写

模型会在每轮结束时更新这个文件,下次启动时注入。这远比从 20 轮之前的聊天里寻找“我做到哪了”可靠。

检查清单:

  • 长期指令是否按项目/团队/个人分层加载?

  • 记忆入口文件是否被强制约束为轻量索引?

  • 会话连续性是否有结构化的 session memory,而非仅依赖聊天记录?

  • 是否预留了 compact 输出空间和缓冲区阈值?

  • 压缩后是否会恢复文件附件、计划和技能约束?

第五章 错误与恢复:把失败路径走成主路

软件世界里最不可信的话就是“正常情况下”。Agent 系统尤其如此:模型会被截断、上下文会超长、hook 会形成死循环、恢复逻辑自己也会失败。

设计原则:错误路径就是主路径。恢复的目标是继续工作,不是礼貌道歉。

5.1 可恢复错误先扣下,分层修复

遇到 prompt too longmax output tokens 等错误,不要第一时间展示给用户,先尝试修复。

分层恢复骨架:

function handleRecoverableError(error, state):
    if error.type == 'prompt_too_long':
        if state.stagedCollapse > 0:
            recoverFromOverflow()             // 第一步:把已排队的 collapse 提交掉
        else if !state.hasAttemptedReactiveCompact:
            tryReactiveCompact()             // 第二步:做一次响应式压缩
        else:
            surfaceErrorAndSkipStopHooks()   // 修复失败,放弃并绕开可能制造死循环的 hooks
    if error.type == 'max_output_tokens':
        if cap < MAX_CAP:
            raiseCapAndRetry()               // 先尝试给模型更大的输出空间
        else:
            appendMetaMessage("请直接从中断处续写,不要道歉,不要 recap") // 再重试

关键原则:恢复路径按破坏性从小到大排列。 不要一上来就做全量压缩,先尝试更便宜的方法。

5.2 防止死循环与熔断

恢复本身也可能失败并形成死循环。比如 prompt too long -> compact -> 仍太长 -> 再 compact -> hook 阻塞 -> 再 compact

防护骨架:

  • 单次防御hasAttemptedReactiveCompact 标记,一旦试过就不再尝试第二次。

  • 熔断机制autoCompact 连续失败 MAX_CONSECUTIVE_FAILURES = 3 次后,直接跳过压缩,把错误暴露出来。

  • 压缩自身 PTL 防护:如果压缩请求本身触发 prompt too long,先丢弃最旧的 API round 再重试。

不变式:

assert hasAttemptedReactiveCompact ⇒ skip further reactive compact
assert consecutiveFailures < MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES

恢复系统如果不加节制,本身就是新的火源。

5.3 中断是失败态的一种

用户 Esc 打断不只是“不看了”,它会产生悬空的 tool_use。错误恢复逻辑必须包括对中断的语义收尾:生成 synthetic error results,记录中断原因,让 transcript 可被审计,而不是留下一段断裂的因果链。

检查清单:

  • 可恢复错误是否先进入恢复分支,而非直接抛给用户?

  • 恢复路径是否分层(轻量尝试 -> 重量压缩 -> 抛出)?

  • 是否有明确机制防止 reactive compact 和 stop hooks 互相咬死?

  • 是否对自动压缩设置了连续失败熔断?

  • 中断是否被当作需要补齐结果的失败态处理?

第六章 多代理与验证:把不确定性关进笼子

单代理的问题不是“能不能做”,而是任务一大,研究、实现、验证挤在同一条上下文链里抢预算。多代理的答案不是简单的并行,而是不确定性分区

6.1 默认隔离,显式共享

Fork 一个子代理时,首要原则是 cache-safe 和状态隔离。

// 骨架: forkAgent()
ctx = createSubagentContext(parent):
    ctx.readFileState = clone(parent.readFileState)
    ctx.abortController = new ChildAbortController(parent) // 子控制器
    ctx.setAppState = noop    // 默认不可修改父状态
    if opt_in.shareSetAppState: ctx.setAppState = parent.setAppState

默认一切可变状态都隔离。 共享必须通过显式 opt-in。这样,子代理的研究误判、临时文件读取、一次性的推理枝杈才不会污染主线程。

6.2 角色分离:实现者不能验证自己

最常见的失败模式是“我改了代码,测试好像通过了,应该没问题”。多代理设计必须强制分离角色。

  • 研究 Worker:探索代码库、文档、日志,产出结构化发现。

  • 实现 Worker:根据 coordinator 的具体指令改代码。

  • 验证 Worker:作为独立 QA,证明代码有效,要跑真实测试,要怀疑一切,不能橡皮图章。

  • 协调者(Coordinator):负责 synthesis。它不能简单转发研究结果,必须自己消化,产出包含具体文件名、函数名和变更计划的明确指令。

核心规则: 验证必须独立成阶段。Coordinator 不能说“你研究的结果你来实现”,也不能让实现者自己给自己做验证。

6.3 Agent 生命周期管理

子代理不是扔出去就不管的黑盒。它们应该有明确的生命周期钩子:

  • SubagentStart:启动时记录 agent_id, agent_type。

  • SubagentStop:终止时记录 transcript 路径,支持 exit code 2 把错误信息回灌给子代理让它再修一轮。

  • 父进程 abort 传播:父任务取消,必须级联取消所有子任务。

  • 资源清理:子代理结束,必须 evict 输出,解除 cleanup handler,防止孤儿进程和内存泄漏。

检查清单:

  • fork 子代理时是否保证了 prompt cache 一致性?

  • 子代理的 mutable state 默认是否隔离?

  • 是否至少分开了研究、实现、验证、综合四种角色?

  • 是否有独立的验证阶段,且不由实现者自己执行?

  • 子代理的生命周期是否可观测、可中止、可清理?

第七章 团队落地:把个人把戏变成组织能力

一个高手可以凭经验驯服 Agent,但一个团队不行。团队需要的是可重复执行的制度。

7.1 第一步:定最低边界,而不是宏大制度

不要一上来就追求 hooks 和复杂编排。先把这些最朴素的事写清楚:

  • 哪些任务允许 Agent 直接做?

  • 哪些改动必须经过人工 review?

  • 做完至少要跑什么验证(lint/typecheck/test)?

  • 哪些目录、命令是绝对禁区(如 rm -rf /git push --force)?

这些边界通常写在分层的 CLAUDE.md 里:团队级写硬约束和协作纪律,项目级写验证口径和常用命令,个人级写偏好。

7.2 审批按后果分层,不按工具名

不要把审批规则做成“Bash 一律禁止”。应按后果划分:

  • 读操作:通常允许。

  • 写工作区:修改代码、生成文件,高风险,通常需要 ask。

  • 不可逆操作:推送代码、修改 CI 配置、访问外部服务,必须 ask 且可审计。

审批模板:

rule:
  name: "禁止危险 Git 操作"
  match: { tool: Bash, args_pattern: "git push --force|git reset --hard" }
  decision: deny
  audit: true

7.3 验证定义先于 skill 数量

团队复用的重点不是有多少个 skill,而是对“完成”的定义是否统一。先把这些固化下来:

  • 改完代码必须跑 npm test -- --related

  • 验证失败不能标记为“已完成但带已知问题”除非明确记录。

  • 凡涉及 API 变更必须有人工验收步骤。

之后再逐步把高频工作流沉淀为 skill。Skill 不只是长 prompt,它要声明:能处理哪类任务、默认调用哪些工具、是直接执行还是 fork 子代理、产出什么可验证结果。

7.4 Hook 是高级能力,最后再引入

SessionStart、PreCompact、SubagentStop 等 hook 很有用,但它们会增加调试成本。基础治理(CLAUDE.md、review 规则、验证标准)没稳住之前,不要急于上 hook。等到团队需要自动归档 transcript、做组织级上下文注入时再引入。

检查清单:

  • 团队是否有分层的 CLAUDE.md,且成员知道什么该写在哪一层?

  • 是否先统一了验证定义,再开始批量制作 skill?

  • 审批规则是否按后果分层,而不是一刀切?

  • 是否有对过期记忆、失效规则的定期维护机制?

  • 是否分清了基线层复盘(Git log/PR/CI)和高阶层审计(transcript/hook event)?

总结:Harness Engineering 十条原则

  1. 把模型当不稳定部件:不要假定它会持续正确。权限、恢复、验证都是为此而生。

  2. Prompt 是控制面:分层、定义优先级、连接记忆治理,不是角色扮演说明书。

  3. Query Loop 是心跳:保持跨轮状态、先治理输入再调模型、明确终止语义。

  4. 工具是受管执行接口:调度先于冲动,并发必须证明安全,中断必须补平账本。

  5. 上下文是工作内存:分层存储、预算管制、压缩为继续工作服务。

  6. 错误路径就是主路径:恢复分层、防死循环、熔断、中断也是失败态。

  7. 恢复的目标是继续工作:截断后续写不道歉,压缩失败先求生存。

  8. 多代理用来分区不确定性:默认隔离、显式共享、角色分离,综合不可外包。

  9. 验证必须独立:实现者不能验证自己,验证要证明有效而非只是确认存在。

  10. 团队制度比个人技巧重要:定边界、统一定义、分层审批、先稳基础再上钩子。

最后一条检查清单: 当你设计 Agent 系统时,请先问自己——如果模型此刻犯了一个低级错误,我的系统能不能体面地停下来、解释清楚发生了什么、并且在下一轮继续推进工作?如果答案模糊,先补 harness。因为一个会道歉的系统不一定成熟,一个知道何时不该开始、何时该重试、何时该中止的系统,才更接近成熟。