Harness Engineering

第4章 上下文工程:比 Prompt Engineering 更重要的事

作者 杨艺韬 · 7,448 字

4.1 一个被忽略的关键区分

过去两年,"Prompt Engineering" 成了 AI 领域最热门的词汇之一。无数文章教你如何写出更好的提示词——用角色扮演、思维链、少样本示例等技巧来引导模型输出。这些技巧确实有用,但当你从写提示词跨越到构建 Agent 系统时,你会发现一个残酷的事实:提示词只是上下文的一小部分,而上下文才是决定 Agent 行为的全部。

让我用一个具体的例子来说明。假设你在 Claude Code 中执行一个任务:"帮我重构这个 React 组件,把状态管理从 useState 迁移到 Zustand"。在模型真正开始思考之前,它看到的不只是你这句话。它看到的是一个精心组装的上下文包,包含:

┌──────────────────────────────────────────┐
│ System Prompt(系统提示)                   │
│ - 角色定义、行为约束、安全规则              │
│ - 工具使用说明                             │
├──────────────────────────────────────────┤
│ Tool Definitions(工具定义)                │
│ - Read、Edit、Bash、Grep 等工具的 schema   │
│ - 每个工具的参数说明和使用约束              │
├──────────────────────────────────────────┤
│ Memory(记忆)                              │
│ - CLAUDE.md 中的项目规则                   │
│ - 用户偏好和历史约定                        │
├──────────────────────────────────────────┤
│ Conversation History(对话历史)            │
│ - 之前的对话轮次                            │
│ - 之前的工具调用和结果                      │
├──────────────────────────────────────────┤
│ Retrieved Context(检索上下文)             │
│ - 被读取的文件内容                          │
│ - 搜索结果                                 │
├──────────────────────────────────────────┤
│ User Message(用户消息)                    │
│ - "帮我重构这个 React 组件..."              │
└──────────────────────────────────────────┘

这个上下文包可能消耗了数万个 token。你的提示词只占其中很小一部分。上下文工程(Context Engineering)就是关于如何设计、组装、压缩和管理这个上下文包的工程实践。

换成工程语言说:Agent 的关键能力不是"写出一段更漂亮的 prompt",而是持续策展上下文,让模型每一轮都看到足够相关、足够新、足够可信的信息。

4.2 为什么上下文工程比提示词工程更重要

4.2.1 Agent 是多轮交互系统

传统的 Prompt Engineering 面向的是单轮交互:你写一段提示词,模型返回一段输出,交互结束。但 Agent 系统是多轮交互的——它会循环执行"思考-行动-观察"的步骤,每一步都会产生新的上下文。

考虑 Claude Code 执行一个稍复杂的任务,比如"找到并修复所有 TypeScript 类型错误"。整个过程可能是这样的:

第1轮:模型决定先运行 tsc --noEmit 查看错误
       → Bash 工具返回 47 行错误信息

第2轮:模型分析错误,决定先读取第一个出错的文件
       → Read 工具返回 200 行代码

第3轮:模型理解了问题,决定修复
       → Edit 工具修改了文件

第4轮:模型决定再运行一次 tsc 验证
       → Bash 工具返回更新后的错误列表

... 可能持续 20-30 轮

每一轮结束后,工具的返回结果都会被追加到上下文中。到第 20 轮时,上下文中已经积累了大量的文件内容、命令输出、编辑历史。如果不加管理,上下文会在几轮之内溢出窗口限制,或者因为无关信息太多而导致模型"迷失"。

4.2.2 上下文窗口有限且昂贵

即使现代模型的上下文窗口已经很大,这个窗口仍然是有限的、昂贵的资源。有三个关键约束:

硬约束:窗口大小。 超过上下文窗口的内容会被截断或导致请求失败。

软约束:注意力衰减。 研究表明,模型对上下文中间位置的信息关注度较低("Lost in the Middle" 问题)。即使技术上放得下,信息放在 100K 上下文的中间地带,模型可能会"忽略"它。

经济约束:token 费用。 每个 token 都有成本。如果你在每轮交互中都传递完整代码库和完整日志,成本和延迟都会迅速失控。

这意味着上下文工程的核心目标是:在有限的窗口内,放入最相关的信息,以最高的信噪比支撑模型做出正确决策。

4.2.3 提示词是静态的,上下文是动态的

提示词在你写完之后基本固定不变。但上下文是随着每一轮交互动态变化的——新的工具结果加入,旧的信息可能需要压缩或移除。这种动态性是 Prompt Engineering 完全无法覆盖的维度。

用一个比喻来说:如果 Prompt Engineering 是写好一份菜单,那么 Context Engineering 就是经营整个厨房——管理食材进出、控制库存、确保每道菜上桌时用的都是最新鲜的原料。

4.3 上下文的七大组成部分

一个 Agent 系统的上下文通常由以下七个部分组成。理解每一部分的特性,是做好上下文工程的基础。

graph TD
    subgraph Context["上下文窗口"]
        direction TB
        SP["① 系统提示<br/>稳定"] --- TD["② 工具定义<br/>半稳定"]
        TD --- CH["③ 对话历史<br/>持续增长"]
        CH --- RM["④ 记忆<br/>跨会话持久"]
        RM --- RD["⑤ 检索文档<br/>按任务注入"]
        RD --- TR["⑥ 工具结果<br/>波动最大"]
        TR --- UM["⑦ 用户消息<br/>高优先级"]
    end
    style SP fill:#dbeafe,stroke:#3b82f6
    style TD fill:#dbeafe,stroke:#3b82f6
    style CH fill:#fce7f3,stroke:#ec4899
    style RM fill:#dcfce7,stroke:#22c55e
    style RD fill:#fef3c7,stroke:#f59e0b
    style TR fill:#fee2e2,stroke:#ef4444
    style UM fill:#f3e8ff,stroke:#a855f7

4.3.1 系统提示(System Prompt)

系统提示是 Harness 注入的"基础人格",定义了 Agent 的角色、能力边界和行为规则。它通常是上下文中最稳定的部分——在整个会话过程中不会改变。

system_prompt = """
You are an expert software engineer.
You have access to the following tools: Read, Edit, Bash, Grep.
Always read a file before editing it.
Never run destructive commands without confirmation.
Respond in the same language as the user.
"""

工程要点: 系统提示应该简洁、精确。每多一句废话,就占用了本可以放更有价值信息的空间。更重要的是,系统提示属于每轮都要携带的固定开销;固定开销越大,动态任务信息的预算就越少。

4.3.2 工具定义(Tool Definitions)

工具定义是 JSON Schema 格式的工具说明,告诉模型有哪些工具可用、每个工具接受什么参数。这部分的开销常常被低估。

以 Claude Code 为例,它定义了 Read、Edit、Write、Bash、Grep、Glob 等工具。每个工具的 schema 加上描述都会占用固定上下文。如果工具数量持续增加,工具定义本身就会成为一项显著的预算开销。

工程要点: 工具定义的质量直接影响模型使用工具的正确率。描述要精确但不冗长。如果可能,使用"延迟加载"策略——不要一次性注入所有工具定义,而是根据当前任务动态选择相关工具。

# 反模式:一次性注入所有工具
tools = load_all_tools()

# 正确做法:根据任务阶段选择工具子集
if task_phase == "analysis":
    tools = [read_tool, grep_tool, glob_tool]
elif task_phase == "editing":
    tools = [read_tool, edit_tool, write_tool, bash_tool]

不过要注意切换的代价:在支持 prompt caching 的 API 中,工具定义位于提示前缀,任务中途更换工具子集会使整个前缀缓存失效,下一次请求要重新支付全量的输入 token。因此更实际的做法往往是保持工具集合稳定,把"按需"做在描述层——例如默认只注入工具名和一句摘要,由模型显式请求后再加载完整定义(defer loading / tool search 思路)。

4.3.3 对话历史(Conversation History)

对话历史是之前所有轮次的用户消息、模型回复、工具调用和工具结果的完整记录。这是上下文中增长最快的部分,也是最需要管理的部分。

一个典型的 Agent 会话中,对话历史的增长曲线大致如下:

Token 消耗

│                                    ╱ 未压缩
│                                 ╱
│                              ╱
│                           ╱
│                        ╱
│                     ╱
│         ────────────────────── 压缩后
│      ╱
│   ╱
│╱
└──────────────────────────────── 轮次

不做任何管理的情况下,长会话的对话历史会持续增长。这不仅占满上下文窗口,还会导致模型把注意力分散到大量不再相关的历史信息上。

4.3.4 记忆(Memory)

记忆是跨会话持久化的信息,比如 Claude Code 中的 CLAUDE.md 文件。它记录了项目规则、用户偏好、常用命令等。

# CLAUDE.md
- 本项目使用 pnpm,不要使用 npm 或 yarn
- 测试框架是 vitest,不是 jest
- 提交信息使用中文
- 代码风格遵循 .eslintrc.js 中的配置

工程要点: 记忆的价值在于避免重复。如果没有记忆,用户每次开始新会话都要重复说明项目规则。但记忆也要定期清理——过时的记忆比没有记忆更糟糕,因为它会误导模型。

4.3.5 检索文档(Retrieved Documents)

当 Agent 需要理解代码库或外部知识时,检索系统会动态拉取相关文档注入上下文。这就是 RAG(Retrieval-Augmented Generation)在 Agent 系统中的体现。

例如,当用户说"帮我按照项目的 API 规范新增一个接口"时,Agent 可能需要:

  1. 检索现有的 API 文件,了解代码风格
  2. 检索路由配置文件,了解路由注册方式
  3. 检索数据库模型定义,了解数据结构

每个检索结果可能有几百到几千 token。关键在于检索的精度——拉取不相关的文档不仅浪费空间,还会干扰模型判断。

4.3.6 工具返回结果(Tool Results)

工具返回结果是上下文中最"重"的部分。它本质上是对话历史(§4.3.3)中增长最快、最占 token 的子集;正是因为它波动最大,我们常常把它作为独立的预算项来单独处理。一次 cat 命令可能返回上千行代码,一次 git log 可能返回几百条提交记录,一次 npm test 可能输出上万字的测试日志。

这是上下文爆炸的主要来源。 如果不对工具结果进行处理,一个 Agent 会话在几轮之内就会把上下文填满。

4.3.7 用户消息(User Message)

用户当前轮次的输入。相比其他部分,这通常是最小的,但优先级最高——Agent 必须首先理解用户想要什么。

4.4 上下文预算分配

既然上下文窗口是有限的,我们就需要像管理财务预算一样管理 token 预算。下面不是固定配额,而是一种预算模型:

┌─────────────────────────────────────────────┐
│ 组成部分              │ 预算策略       │ 管理方式 │
├─────────────────────────────────────────────┤
│ System Prompt         │ 固定且尽量小   │ 版本化   │
│ Tool Definitions      │ 半固定         │ 按需加载 │
│ Conversation History  │ 可增长         │ 压缩摘要 │
│ Memory (CLAUDE.md)    │ 小而稳定       │ 定期清理 │
│ Retrieved Documents   │ 按任务注入     │ 检索排序 │
│ Tool Results (当轮)    │ 波动最大       │ 截断摘要 │
│ User Message (当轮)    │ 高优先级       │ 原样保留 │
│ 预留 (安全边际)        │ 必须保留       │ 防止超窗 │
├─────────────────────────────────────────────┤
│ 总计                   │ 不超过模型窗口 │ 动态调整 │
└─────────────────────────────────────────────┘

这个分配不是固定的,而是动态调整的。在会话早期,对话历史很短,可以给检索文档和工具结果更多空间。在会话后期,对话历史占据主导,检索文档和工具结果需要更积极地压缩。

实践原则:

  1. 固定开销最小化。 系统提示、工具定义、记忆这些在每轮都会出现的部分,要尽可能精简。省下的空间都是给动态内容的。

  2. 最新信息优先。 当空间不足时,优先保留最近几轮的完整信息,压缩或丢弃更早的历史。因为模型在多数情况下需要最近的上下文来做出连贯的决策。

  3. 保留关键锚点。 即使压缩历史,也要保留关键的决策点——比如用户的原始需求、重要的中间结论、关键的错误信息。这些锚点帮助模型维持对整体任务的理解。

4.4.1 上下文预算的决策表

预算分配不能只按 token 数做机械切分,还要看信息对当前决策的作用。一个实用的判断表如下:

信息类型 默认处理 升级保留的条件 降级/丢弃的条件
用户原始目标 原样保留 任务跨多轮、需求多次变更 几乎不丢弃,只做短摘要备份
当前计划 原样保留 计划仍在执行中 计划已完成或被用户明确替换
文件内容 保留相关片段 即将编辑、测试失败指向该文件 只是背景阅读且后续未引用
命令输出 保留错误和摘要 包含失败原因、路径、退出码 成功日志、下载进度、重复输出
搜索结果 保留命中列表和少量上下文 后续步骤依赖文件集合 已经读取了具体目标文件
历史推理 保留结论和分歧点 方案选择仍影响后续执行 中间探索已被新事实取代
项目规则 保留适用规则 与当前文件或工具直接相关 与任务无关、过期、可按需读取

这个表的核心思想是:上下文不是档案库,而是决策工作台。工作台上应该放当前要用的工具、材料和图纸;已经用完的材料要归档,可能稍后要用的材料要能快速取回,而不是一直堆在手边。

还要区分"删除"和"可恢复"。很多信息不应该永久丢失,只是不应该继续占据模型上下文。例如完整测试日志可以写入 transcript 或观测系统,模型上下文只保留失败摘要和路径;完整历史对话可以保留在会话存储里,当前请求只携带压缩摘要。这样既不牺牲可追溯性,也不让模型每轮背负全部历史。

4.5 上下文压缩:对话紧缩策略

当对话历史接近上下文窗口的限制时,Harness 需要执行"对话紧缩"(Conversation Compaction)。这是 Claude Code 中一个非常精巧的机制,值得深入了解。

4.5.1 压缩的触发时机

flowchart LR
    A["每轮迭代后"] --> B{"达到压缩阈值?"}
    B -->|否| C["继续正常对话"]
    B -->|是| D["触发自动压缩"]
    D --> E["用 LLM 摘要<br/>旧消息"]
    E --> F["保留最近 N 轮<br/>+ 摘要"]
    F --> G["释放 token 空间"]
    G --> C
    U["用户输入 /compact"] --> D

Claude Code 的本地快照中,自动压缩阈值不是写死的百分比,而是用有效上下文窗口减去缓冲区来计算:AUTOCOMPACT_BUFFER_TOKENS = 13_000getAutoCompactThreshold() 返回 effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS,同时支持 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 作为测试覆盖(src/services/compact/autoCompact.ts:62-90)。用户也可以主动触发 /compact

# 通用示意:按使用比例触发。这不是 Claude Code 的实现——
# 它用"有效窗口 − 缓冲区"的绝对阈值(见上文)
def should_compact(context_tokens, max_tokens):
    usage_ratio = context_tokens / max_tokens
    return usage_ratio > 0.80

RECENT_ROUNDS = 3  # 保留最近 N 轮原始对话

def compact(messages, system_prompt):
    # 使用模型自身来总结对话历史
    summary = model.summarize(
        messages=messages,
        instruction="总结这段对话的关键信息,包括:用户的目标、已完成的步骤、遇到的问题、当前状态。保留所有文件路径、代码片段和具体的技术细节。"
    )

    # 用摘要替换原始对话历史
    compacted = [
        {"role": "system", "content": system_prompt},
        {"role": "assistant", "content": f"[对话历史摘要]\n{summary}"},
        *messages[-RECENT_ROUNDS:]
    ]
    return compacted

4.5.2 压缩的质量控制

压缩的核心挑战是:如何在减少 token 的同时不丢失关键信息。以下是几个关键实践:

保留具体细节,丢弃冗余过程。 比如,如果 Agent 花了 5 轮才找到一个 bug,压缩后只需要保留"在文件 X 的第 Y 行发现了 Z 类型的 bug",而不需要保留中间的搜索过程。

保留决策依据,丢弃探索过程。 如果 Agent 考虑了方案 A、B、C,最终选择了 B,压缩后应保留"选择方案 B 的原因是...",而不需要保留对 A 和 C 的详细分析。

保留文件路径和代码关键行。 这些是后续编辑操作的基础。丢失文件路径意味着 Agent 需要重新搜索,浪费工具调用。

4.5.3 分层压缩策略

更高级的压缩策略是分层的:

距离当前轮次    保留级别
─────────────────────────
1-3 轮前       完整保留(原始消息+工具结果)
4-10 轮前      保留消息,压缩工具结果
11-20 轮前     只保留关键决策和结论
20 轮以上      合并为高级摘要

这种策略模仿了人类记忆的工作方式:最近的事件记得最清楚,越远的事件越模糊,但关键事件(如重大决策、转折点)会被长期保留。

4.6 工具结果的摘要化处理

工具结果是上下文膨胀的"元凶"。一个不加处理的 ls -la 可能返回几百行,一个 git diff 可能返回几千行。高效的 Harness 会在工具结果进入上下文之前进行智能处理。

4.6.1 截断与摘要

最简单的策略是截断——限制工具结果的最大长度。但粗暴截断可能丢失关键信息。更好的做法是结合截断和摘要:

def process_tool_result(tool_name, raw_result, max_tokens=3000):
    token_count = count_tokens(raw_result)

    if token_count <= max_tokens:
        return raw_result  # 不需要处理

    # 根据工具类型采取不同策略
    if tool_name == "Bash":
        return truncate_with_head_tail(raw_result, max_tokens)
    elif tool_name == "Read":
        # 代码文件也需要遵守同一上限;实际系统往往给 Read 单独的更大阈值
        return truncate_with_head_tail(raw_result, max_tokens)
    elif tool_name == "Grep":
        return keep_top_matches(raw_result, max_tokens)
    else:
        return summarize_with_model(raw_result, max_tokens)

def truncate_with_head_tail(text, max_tokens):
    """保留开头和结尾,中间用省略号标记。max_tokens 用于估算可保留行数。"""
    lines = text.split('\n')
    # 粗略按每行约 12 tokens 估算(代码/日志的典型量级);
    # 生产环境应使用 tokenizer 精确计算
    tokens_per_line = 12
    half_lines = max_tokens // (2 * tokens_per_line)
    if len(lines) <= 2 * half_lines:
        return text
    head = '\n'.join(lines[:half_lines])
    tail = '\n'.join(lines[-half_lines:])
    omitted = len(lines) - 2 * half_lines
    return f"{head}\n\n... ({omitted} lines omitted) ...\n\n{tail}"

4.6.2 结构化提取

对于特定类型的工具输出,可以做结构化提取:

# 测试输出:只保留失败的测试
def extract_test_failures(test_output):
    total, passed, failures = parse_failures(test_output)
    summary = f"共 {total} 个测试,{passed} 个通过,{len(failures)} 个失败。\n"
    for f in failures:
        summary += f"\n{f.name}: {f.error_message}"
        summary += f"\n   位置: {f.file}:{f.line}"
    return summary

# TypeScript 编译错误:只保留错误信息
def extract_tsc_errors(tsc_output):
    errors = parse_tsc_errors(tsc_output)
    return "\n".join(
        f"{e.file}:{e.line} - {e.code}: {e.message}"
        for e in errors
    )

这样处理后,原本很长的测试输出可以被压缩成只包含失败用例、错误位置和关键堆栈的摘要;关键不是压缩比例,而是保留足够支撑下一步决策的信息。

4.6.3 增量式结果

对于需要多次调用的工具(比如多次运行测试),可以只展示与上次相比的增量变化:

def diff_test_results(previous, current):
    newly_passed_count = current.passed - previous.passed
    previous_failed_names = {f.name for f in previous.failures}
    newly_failed_cases = [f for f in current.failures if f.name not in previous_failed_names]
    newly_failed_count = len(newly_failed_cases)

    summary = f"与上次相比: +{newly_passed_count} 通过, +{newly_failed_count} 失败\n"
    summary += f"剩余失败: {len(current.failures)}\n"
    for f in newly_failed_cases:
        summary += f"  新增失败: {f.name} - {f.error}\n"
    return summary

4.7 检索增强的上下文构建

在大型代码库中,Agent 不可能把所有代码都放入上下文。它需要一个检索系统来"按需拉取"相关信息。这就是 Retrieval-Augmented Context 的核心理念。

4.7.1 多级检索策略

高效的检索系统通常采用多级策略:

第一级:文件级检索。 根据用户意图和当前任务,确定需要查看哪些文件。这通常通过文件名匹配(Glob)和内容搜索(Grep)来实现。

# 用户说"修复登录页面的 bug"
# 第一级检索:找到相关文件
# Python 标准库 glob 不支持花括号展开,需要按扩展名分别匹配
files = []
for ext in ("ts", "tsx", "vue"):
    files.extend(glob(f"**/login*.{ext}", recursive=True))
files += grep("LoginPage|LoginForm|useAuth", type="ts")

第二级:片段级检索。 找到文件后,不一定要读取整个文件。如果一个文件有 2000 行,只需要读取相关的函数或类。

# 第二级检索:只读取相关片段
content = read_file("src/pages/Login.tsx",
                     start_line=45,   # handleSubmit 函数开始
                     end_line=120)    # handleSubmit 函数结束

第三级:语义级检索。 对于更复杂的场景,使用嵌入向量进行语义搜索,找到与当前问题语义最相关的代码片段。

4.7.2 上下文中的信息排列

检索到的信息放在上下文的什么位置也很重要。根据 "Lost in the Middle" 研究,模型对上下文开头和结尾的信息关注度更高。因此:

  • 最重要的信息放在上下文的开头或结尾
  • 辅助性的背景信息放在中间
  • 用户的当前消息始终放在最后(这是大多数 API 的默认行为)

4.8 上下文工程的思维模式

到这里,我们已经讨论了上下文工程的各种技术手段。但最重要的不是具体技术,而是一种思维模式的转变。

4.8.1 从"倾倒"到"策展"

初学者的做法是"倾倒"——把所有可能有用的信息都塞进上下文,让模型自己去找需要的。这就像把整个图书馆的书倒在桌子上,然后让人从中找到答案。

高手的做法是"策展"——像博物馆策展人一样,精心挑选每一件展品,确保每一件都有意义,整体形成一个连贯的叙事。

# 反模式:倾倒一切
context = {
    "all_source_files": read_entire_repo(),
    "all_git_history": git_log("--all"),
    "all_docs": read_all_docs(),
    "user_message": "修复这个 bug"
}
# 结果:上下文溢出,模型无法找到重点

# 正确做法:策展相关信息
context = {
    "bug_report": issue_description,
    "relevant_file": read_file(buggy_file),
    "related_test": read_file(test_file),
    "error_log": extract_error(log_output),
    "user_message": "修复这个 bug"
}
# 结果:上下文更短,信号更集中,模型更容易定位问题

4.8.2 从"一次性"到"持续管理"

上下文不是设置一次就完事的,它需要在整个 Agent 会话生命周期中持续管理。就像一个运行中的服务器需要持续的运维一样,上下文也需要:

  • 监控: 实时追踪 token 使用量
  • 清理: 定期压缩和丢弃过时信息
  • 补充: 根据任务进展动态拉取新信息
  • 优化: 根据模型的行为反馈调整上下文构成

4.8.3 信噪比是核心指标

衡量上下文工程质量的核心指标是信噪比(Signal-to-Noise Ratio)。如果上下文中大部分内容都和当前决策无关,模型就要在噪音中寻找少量信号,效率和准确率都会下降。

优秀的上下文工程不追求"塞满窗口",而追求"每一段信息都有明确用途":它要么定义行为边界,要么提供当前任务事实,要么保留关键历史,要么支持下一步验证。说不清用途的信息,就应该被延迟加载、摘要或丢弃。

4.9 常见反模式与修正

在实际构建 Agent 系统时,有几个常见的上下文工程反模式需要警惕。

反模式一:无限制的工具结果

# 问题:把原始的 npm install 输出全部放入上下文
result = bash("npm install")
# 可能是几百行的依赖解析信息、下载进度等

# 修正:只保留关键信息
result = bash("npm install")
processed = extract_summary(result)
# "安装完成。新增 3 个包,更新 2 个包,共 847 个包。"

反模式二:没有压缩策略的长会话

# 问题:20 轮对话后,上下文中还保留着第 1 轮读取的文件内容
messages = []
for turn in range(20):
    messages.append(user_input())
    response = model.call(messages=messages)
    messages.append(response)
    tool_results = execute_tools(response)
    messages.extend(tool_results)
    # messages 只增不减,最终爆掉

# 修正:在每轮结束后检查并压缩
messages = []
system_prompt = "You are a helpful coding assistant."
for turn in range(20):
    if should_compact(messages):
        messages = compact(messages, system_prompt)
    messages.append(user_input())
    response = model.call(messages=messages)
    messages.append(response)
    tool_results = execute_tools(response)
    messages.extend(tool_results)

反模式三:忽略 token 成本

# 问题:每轮都重新读取整个项目配置
def on_each_turn():
    config = read_file("tsconfig.json")          # 每轮 200 token
    eslint = read_file(".eslintrc.js")            # 每轮 300 token
    package = read_file("package.json")           # 每轮 500 token
    # 20 轮下来,光配置文件就重复消耗了 20000 token

# 修正:缓存不变的信息,只在需要时重新读取
cached_config = None
def on_each_turn():
    global cached_config
    if cached_config is None or config_changed():
        cached_config = read_configs()
    # 后续轮次直接使用缓存

反模式四:上下文中的信息矛盾

这是最隐蔽也最危险的反模式。当上下文中包含互相矛盾的信息时,模型的行为会变得不可预测。

# 场景:
# - CLAUDE.md 中写着"使用 ESLint 进行代码检查"
# - 第 5 轮的工具结果显示项目使用的是 Biome
# - 用户在第 8 轮说"用我们的 linter 检查一下"

# 模型可能会困惑:到底用 ESLint 还是 Biome?

修正方案:当发现矛盾时,Harness 应该主动更新或标注。最近的信息应该覆盖旧的信息,并且明确标记哪些是最新的。

反模式五:把上下文当"垃圾桶"

有些系统在每轮开始时都会注入大量的"以防万一"信息——完整的 API 文档、所有可能的错误码表、项目的全部文件列表。这种做法的逻辑是"宁可多了也不能少了",但实际效果恰恰相反。

信息过载会导致模型的"选择困难"。当上下文中有太多信息时,模型会在不同信息之间犹豫不决,输出质量反而下降。记住:少即是多,精即是准。

4.10 本地快照:Claude Code 上下文工程子系统的代码量

§4.5 讨论"压缩触发时机 / 质量控制 / 分层策略"。这些抽象在本地 src/services/ 里有具体实现。需要说明:以下文件路径、行号和常数来自作者本地保存的 Claude Code 源码快照,用于展示工程落地规模;公开版本或不同分支的结构与数字可能不一致。

src/services/compact/ 11 文件 3960 行

文件 角色
compact.ts 1705 本目录最大——主压缩流程(编排 + state 管理 + 失败重试)
sessionMemoryCompact.ts 630 session memory 写入式压缩(默认 maxTokens: 40_000
microCompact.ts 530 微压缩——比对话紧缩小一档的局部摘要
prompt.ts 374 压缩用的 LLM prompt 模板
autoCompact.ts 351 自动触发逻辑——getAutoCompactThreshold 函数
apiMicrocompact.ts 153 API 层的微压缩入口
postCompactCleanup.ts 77 压缩后清理
grouping.ts 63 消息分组算法
其余(compactWarningHook / compactWarningState / timeBasedMCConfig) 余下 警告 UI + 时间窗口配置

src/services/SessionMemory/ + extractMemories/ 共 1795 行——对应 §4.3.4 "记忆"组件的实现。

合计 5755 行,专门处理压缩、session memory 和 memory extraction;对比 src/services/ 的 53495 行,这是服务层里相当可观的一块。

两条值得记住的源码常数

  1. AUTOCOMPACT_BUFFER_TOKENS = 13_000src/services/compact/autoCompact.ts:62)——自动压缩阈值是有效窗口减去缓冲区。这样做是为了给压缩请求本身预留输入和输出空间。
  2. sessionMemoryCompact.ts 默认 maxTokens: 40_000src/services/compact/sessionMemoryCompact.ts:45-60)——session memory 有自己的压缩上限。上下文管理不是只有一次全局 compact,而是有对话压缩和 memory 内部压缩的分层。

microCompact.ts 530 行 + apiMicrocompact.ts 153 行 = 683 行专给"微压缩"。这是一种介于 §4.6 "工具结果摘要" 和 §4.5 "全局压缩" 之间的策略:不等到全局 compact 才处理上下文压力,而是在工具结果或局部历史变重时先做局部整理。

autoCompact.ts:79-89 还提供 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE,用于按百分比覆盖阈值。这是生产代码保留调试出口的范例:默认策略稳定,测试和诊断时可以显式覆盖。

4.11 本章小结

上下文工程是 Agent 系统设计中最具杠杆效应的环节。它不像提示词工程那样有大量的"技巧集锦"可以速成,而是需要系统性的工程思维。

核心要点回顾:

  1. 区分 Prompt 和 Context。 提示词只是上下文的一小部分,上下文才是决定 Agent 行为的全部输入。

  2. 理解上下文的七大组成部分。 系统提示、工具定义、对话历史、记忆、检索文档、工具结果、用户消息——每一部分都有其特性和管理策略。

  3. 做好预算分配。 把上下文窗口当作有限的预算来管理,为每个组成部分设定合理的 token 额度。

  4. 实施压缩策略。 分层压缩、增量更新、结构化提取——确保上下文在整个会话生命周期中保持高信噪比。

  5. 避免反模式。 无限制的工具结果、缺失的压缩策略、忽略 token 成本、信息矛盾、信息过载——这些都是上下文工程的常见陷阱。

物理事实:Claude Code 上下文工程子系统在本地快照中包括 compact/ 11 文件 3960 行,SessionMemory/ + extractMemories/ 1795 行,合计 5755 行;AUTOCOMPACT_BUFFER_TOKENS = 13_000 和 session memory 默认 maxTokens = 40_000 说明它采用了全局压缩、session memory 压缩和微压缩并存的分层策略。