《Harness Engineering --- AI Agent 工程方法论》完整目录
- 前言
- 第1章 Agent 不等于大模型:Harness 的价值
- 第2章 Agent 架构模式全景
- 第3章 Agent Loop:心跳与决策循环
- 第4章 上下文工程:比 Prompt Engineering 更重要的事(当前)
- 第5章 Tool Design:给 Agent 造趁手的兵器
- 第6章 工具编排与并发执行
- 第7章 工具结果处理与错误恢复
- 第8章 System Prompt 分层设计
- 第9章 指令优先级与冲突消解
- 第10章 Few-shot、CoT 与动态提示策略
- 第11章 短期记忆:上下文窗口管理
- 第12章 长期记忆:持久化与检索
- 第13章 多轮对话与会话状态机
- 第14章 Agent 权限模型设计
- 第15章 沙箱、隔离与防御性编程
- 第16章 多 Agent 协调模式
- 第17章 Human-in-the-Loop:人机协作设计
- 第18章 评估与测试方法论
- 第19章 可观测性与调试
- 第20章 成本控制与性能优化
- 第21章 设计模式与架构决策
第4章 上下文工程:比 Prompt Engineering 更重要的事
4.1 一个被忽略的关键区分
过去两年,"Prompt Engineering" 成了 AI 领域最热门的词汇之一。无数文章教你如何写出更好的提示词------用角色扮演、思维链、少样本示例等技巧来引导模型输出。这些技巧确实有用,但当你从写提示词跨越到构建 Agent 系统时,你会发现一个残酷的事实:提示词只是上下文的一小部分,而上下文才是决定 Agent 行为的全部。
让我用一个具体的例子来说明。假设你在 Claude Code 中执行一个任务:"帮我重构这个 React 组件,把状态管理从 useState 迁移到 Zustand"。在模型真正开始思考之前,它看到的不只是你这句话。它看到的是一个精心组装的上下文包,包含:
sql
┌──────────────────────────────────────────┐
│ System Prompt(系统提示) │
│ - 角色定义、行为约束、安全规则 │
│ - 工具使用说明 │
├──────────────────────────────────────────┤
│ Tool Definitions(工具定义) │
│ - Read、Edit、Bash、Grep 等工具的 schema │
│ - 每个工具的参数说明和使用约束 │
├──────────────────────────────────────────┤
│ Memory(记忆) │
│ - CLAUDE.md 中的项目规则 │
│ - 用户偏好和历史约定 │
├──────────────────────────────────────────┤
│ Conversation History(对话历史) │
│ - 之前的对话轮次 │
│ - 之前的工具调用和结果 │
├──────────────────────────────────────────┤
│ Retrieved Context(检索上下文) │
│ - 被读取的文件内容 │
│ - 搜索结果 │
├──────────────────────────────────────────┤
│ User Message(用户消息) │
│ - "帮我重构这个 React 组件..." │
└──────────────────────────────────────────┘
这个上下文包可能消耗了数万个 token。你的提示词只占其中很小一部分。上下文工程(Context Engineering)就是关于如何设计、组装、压缩和管理这个上下文包的工程实践。
Andrej Karpathy 曾这样总结:
"The hottest new programming language is English." 但如果要更精确一点,应该说:"The hottest new programming skill is context curation."
4.2 为什么上下文工程比提示词工程更重要
4.2.1 Agent 是多轮交互系统
传统的 Prompt Engineering 面向的是单轮交互:你写一段提示词,模型返回一段输出,交互结束。但 Agent 系统是多轮交互的------它会循环执行"思考-行动-观察"的步骤,每一步都会产生新的上下文。
考虑 Claude Code 执行一个稍复杂的任务,比如"找到并修复所有 TypeScript 类型错误"。整个过程可能是这样的:
markdown
第1轮:模型决定先运行 tsc --noEmit 查看错误
→ Bash 工具返回 47 行错误信息
第2轮:模型分析错误,决定先读取第一个出错的文件
→ Read 工具返回 200 行代码
第3轮:模型理解了问题,决定修复
→ Edit 工具修改了文件
第4轮:模型决定再运行一次 tsc 验证
→ Bash 工具返回更新后的错误列表
... 可能持续 20-30 轮
每一轮结束后,工具的返回结果都会被追加到上下文中。到第 20 轮时,上下文中已经积累了大量的文件内容、命令输出、编辑历史。如果不加管理,上下文会在几轮之内溢出窗口限制,或者因为无关信息太多而导致模型"迷失"。
4.2.2 上下文窗口有限且昂贵
即使现代模型的上下文窗口已经扩展到了 100K 甚至 1M token,这个窗口仍然是有限的、昂贵的资源。有两个关键约束:
硬约束:窗口大小。 超过上下文窗口的内容会被截断或导致请求失败。
软约束:注意力衰减。 研究表明,模型对上下文中间位置的信息关注度较低("Lost in the Middle" 问题)。即使技术上放得下,信息放在 100K 上下文的中间地带,模型可能会"忽略"它。
经济约束:token 费用。 每个 token 都有成本。如果你在每轮交互中都传递一个完整的 10 万行代码库,你的 API 账单会让你怀疑人生。
这意味着上下文工程的核心目标是:在有限的窗口内,放入最相关的信息,以最高的信噪比支撑模型做出正确决策。
4.2.3 提示词是静态的,上下文是动态的
提示词在你写完之后基本固定不变。但上下文是随着每一轮交互动态变化的------新的工具结果加入,旧的信息可能需要压缩或移除。这种动态性是 Prompt Engineering 完全无法覆盖的维度。
用一个比喻来说:如果 Prompt Engineering 是写好一份菜单,那么 Context Engineering 就是经营整个厨房------管理食材进出、控制库存、确保每道菜上桌时用的都是最新鲜的原料。
4.3 上下文的七大组成部分
一个 Agent 系统的上下文通常由以下七个部分组成。理解每一部分的特性,是做好上下文工程的基础。
4.3.1 系统提示(System Prompt)
系统提示是 Harness 注入的"基础人格",定义了 Agent 的角色、能力边界和行为规则。它通常是上下文中最稳定的部分------在整个会话过程中不会改变。
python
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.
"""
工程要点: 系统提示应该简洁、精确。每多一句废话,就占用了本可以放更有价值信息的空间。在 Claude Code 中,系统提示大约占用 3000-5000 token,这是一个经过精心优化的数字。
4.3.2 工具定义(Tool Definitions)
工具定义是 JSON Schema 格式的工具说明,告诉模型有哪些工具可用、每个工具接受什么参数。这部分的开销常常被低估。
以 Claude Code 为例,它定义了 Read、Edit、Write、Bash、Grep、Glob 等工具。每个工具的 schema 加上描述,大约占用 200-500 token。如果你有 10 个工具,这就是 2000-5000 token 的固定开销。
工程要点: 工具定义的质量直接影响模型使用工具的正确率。描述要精确但不冗长。如果可能,使用"延迟加载"策略------不要一次性注入所有工具定义,而是根据当前任务动态选择相关工具。
python
# 反模式:一次性注入所有 50 个工具
tools = load_all_tools() # 消耗 15000 token
# 正确做法:根据任务阶段选择工具子集
if task_phase == "analysis":
tools = [read_tool, grep_tool, glob_tool] # 1500 token
elif task_phase == "editing":
tools = [read_tool, edit_tool, write_tool, bash_tool] # 2000 token
4.3.3 对话历史(Conversation History)
对话历史是之前所有轮次的用户消息、模型回复、工具调用和工具结果的完整记录。这是上下文中增长最快的部分,也是最需要管理的部分。
一个典型的 Agent 会话中,对话历史的增长曲线大致如下:
Token 消耗
│
│ ╱ 未压缩
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ────────────────────── 压缩后
│ ╱
│ ╱
│╱
└──────────────────────────────── 轮次
不做任何管理的情况下,一个 20 轮的 Agent 会话可能消耗 50K-100K token 的对话历史。这不仅占满了上下文窗口,还会导致模型把注意力分散到大量不再相关的历史信息上。
4.3.4 记忆(Memory)
记忆是跨会话持久化的信息,比如 Claude Code 中的 CLAUDE.md 文件。它记录了项目规则、用户偏好、常用命令等。
markdown
# CLAUDE.md
- 本项目使用 pnpm,不要使用 npm 或 yarn
- 测试框架是 vitest,不是 jest
- 提交信息使用中文
- 代码风格遵循 .eslintrc.js 中的配置
工程要点: 记忆的价值在于避免重复。如果没有记忆,用户每次开始新会话都要重复说明项目规则。但记忆也要定期清理------过时的记忆比没有记忆更糟糕,因为它会误导模型。
4.3.5 检索文档(Retrieved Documents)
当 Agent 需要理解代码库或外部知识时,检索系统会动态拉取相关文档注入上下文。这就是 RAG(Retrieval-Augmented Generation)在 Agent 系统中的体现。
例如,当用户说"帮我按照项目的 API 规范新增一个接口"时,Agent 可能需要:
- 检索现有的 API 文件,了解代码风格
- 检索路由配置文件,了解路由注册方式
- 检索数据库模型定义,了解数据结构
每个检索结果可能有几百到几千 token。关键在于检索的精度------拉取不相关的文档不仅浪费空间,还会干扰模型判断。
4.3.6 工具返回结果(Tool Results)
工具返回结果是上下文中最"重"的部分。一次 cat 命令可能返回上千行代码,一次 git log 可能返回几百条提交记录,一次 npm test 可能输出上万字的测试日志。
这是上下文爆炸的主要来源。 如果不对工具结果进行处理,一个 Agent 会话在几轮之内就会把上下文填满。
4.3.7 用户消息(User Message)
用户当前轮次的输入。相比其他部分,这通常是最小的,但优先级最高------Agent 必须首先理解用户想要什么。
4.4 上下文预算分配
既然上下文窗口是有限的,我们就需要像管理财务预算一样管理 token 预算。以下是一个基于 200K 上下文窗口的典型预算分配方案:
erlang
┌─────────────────────────────────────────────┐
│ 组成部分 │ Token 预算 │ 占比 │
├─────────────────────────────────────────────┤
│ System Prompt │ 4,000 │ 2% │
│ Tool Definitions │ 3,000 │ 1.5% │
│ Memory (CLAUDE.md) │ 2,000 │ 1% │
│ Conversation History │ 120,000 │ 60% │
│ Retrieved Documents │ 30,000 │ 15% │
│ Tool Results (当轮) │ 30,000 │ 15% │
│ User Message (当轮) │ 1,000 │ 0.5% │
│ 预留 (安全边际) │ 10,000 │ 5% │
├─────────────────────────────────────────────┤
│ 总计 │ 200,000 │ 100% │
└─────────────────────────────────────────────┘
这个分配不是固定的,而是动态调整的。在会话早期,对话历史很短,可以给检索文档和工具结果更多空间。在会话后期,对话历史占据主导,检索文档和工具结果需要更积极地压缩。
实践原则:
-
固定开销最小化。 系统提示、工具定义、记忆这些在每轮都会出现的部分,要尽可能精简。省下的空间都是给动态内容的。
-
最新信息优先。 当空间不足时,优先保留最近几轮的完整信息,压缩或丢弃更早的历史。因为模型在多数情况下需要最近的上下文来做出连贯的决策。
-
保留关键锚点。 即使压缩历史,也要保留关键的决策点------比如用户的原始需求、重要的中间结论、关键的错误信息。这些锚点帮助模型维持对整体任务的理解。
4.5 上下文压缩:对话紧缩策略
当对话历史接近上下文窗口的限制时,Harness 需要执行"对话紧缩"(Conversation Compaction)。这是 Claude Code 中一个非常精巧的机制,值得深入了解。
4.5.1 压缩的触发时机
Claude Code 采用的策略是:当上下文使用率超过一定阈值(通常是 80% 左右)时,自动触发压缩。也可以由用户主动触发(输入 /compact 命令)。
python
def should_compact(context_tokens, max_tokens):
usage_ratio = context_tokens / max_tokens
return usage_ratio > 0.80
def compact_conversation(messages, system_prompt):
# 使用模型自身来总结对话历史
summary = model.summarize(
messages=messages,
instruction="总结这段对话的关键信息,包括:用户的目标、已完成的步骤、遇到的问题、当前状态。保留所有文件路径、代码片段和具体的技术细节。"
)
# 用摘要替换原始对话历史
compacted = [
{"role": "system", "content": system_prompt},
{"role": "assistant", "content": f"[对话历史摘要]\n{summary}"},
# 保留最近 N 轮原始对话
*messages[-N:]
]
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 截断与摘要
最简单的策略是截断------限制工具结果的最大长度。但粗暴截断可能丢失关键信息。更好的做法是结合截断和摘要:
python
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":
return raw_result # 代码文件通常需要完整保留
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):
"""保留开头和结尾,中间用省略号标记"""
lines = text.split('\n')
head = '\n'.join(lines[:30])
tail = '\n'.join(lines[-30:])
omitted = len(lines) - 60
return f"{head}\n\n... ({omitted} lines omitted) ...\n\n{tail}"
4.6.2 结构化提取
对于特定类型的工具输出,可以做结构化提取:
python
# 测试输出:只保留失败的测试
def extract_test_failures(test_output):
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
)
这样处理后,一个原本 5000 token 的测试输出可能被压缩到 500 token,但关键信息完全保留。
4.6.3 增量式结果
对于需要多次调用的工具(比如多次运行测试),可以只展示与上次相比的增量变化:
python
def diff_test_results(previous, current):
newly_passed = current.passed - previous.passed
newly_failed = current.failed - previous.failed
summary = f"与上次相比: +{newly_passed} 通过, +{newly_failed} 失败\n"
summary += f"剩余失败: {current.failures}\n"
for f in newly_failed:
summary += f" 新增失败: {f.name} - {f.error}\n"
return summary
4.7 检索增强的上下文构建
在大型代码库中,Agent 不可能把所有代码都放入上下文。它需要一个检索系统来"按需拉取"相关信息。这就是 Retrieval-Augmented Context 的核心理念。
4.7.1 多级检索策略
高效的检索系统通常采用多级策略:
第一级:文件级检索。 根据用户意图和当前任务,确定需要查看哪些文件。这通常通过文件名匹配(Glob)和内容搜索(Grep)来实现。
python
# 用户说"修复登录页面的 bug"
# 第一级检索:找到相关文件
files = glob("**/login*.{ts,tsx,vue}")
files += grep("LoginPage|LoginForm|useAuth", type="ts")
第二级:片段级检索。 找到文件后,不一定要读取整个文件。如果一个文件有 2000 行,只需要读取相关的函数或类。
python
# 第二级检索:只读取相关片段
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 从"倾倒"到"策展"
初学者的做法是"倾倒"------把所有可能有用的信息都塞进上下文,让模型自己去找需要的。这就像把整个图书馆的书倒在桌子上,然后让人从中找到答案。
高手的做法是"策展"------像博物馆策展人一样,精心挑选每一件展品,确保每一件都有意义,整体形成一个连贯的叙事。
python
# 反模式:倾倒一切
context = {
"all_source_files": read_entire_repo(), # 500K token
"all_git_history": git_log("--all"), # 100K token
"all_docs": read_all_docs(), # 200K token
"user_message": "修复这个 bug" # 10 token
}
# 结果:上下文溢出,模型无法找到重点
# 正确做法:策展相关信息
context = {
"bug_report": issue_description, # 200 token
"relevant_file": read_file(buggy_file), # 800 token
"related_test": read_file(test_file), # 400 token
"error_log": extract_error(log_output), # 300 token
"user_message": "修复这个 bug" # 10 token
}
# 结果:1710 token,模型可以精准定位问题
4.8.2 从"一次性"到"持续管理"
上下文不是设置一次就完事的,它需要在整个 Agent 会话生命周期中持续管理。就像一个运行中的服务器需要持续的运维一样,上下文也需要:
- 监控: 实时追踪 token 使用量
- 清理: 定期压缩和丢弃过时信息
- 补充: 根据任务进展动态拉取新信息
- 优化: 根据模型的行为反馈调整上下文构成
4.8.3 信噪比是核心指标
衡量上下文工程质量的核心指标是信噪比(Signal-to-Noise Ratio)。如果你的上下文有 100K token,但其中只有 10K token 是模型真正需要的,那你的信噪比只有 10%。这意味着模型要在 90% 的噪音中搜索 10% 的信号,效率和准确率都会大打折扣。
优秀的上下文工程应该追求 50% 以上的信噪比------上下文中一半以上的内容都是对当前决策直接有用的。
4.9 常见反模式与修正
在实际构建 Agent 系统时,有几个常见的上下文工程反模式需要警惕。
反模式一:无限制的工具结果
python
# 问题:把原始的 npm install 输出全部放入上下文
result = bash("npm install")
# 可能是几百行的依赖解析信息、下载进度等
# 修正:只保留关键信息
result = bash("npm install")
processed = extract_summary(result)
# "安装完成。新增 3 个包,更新 2 个包,共 847 个包。"
反模式二:没有压缩策略的长会话
python
# 问题: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 = []
for turn in range(20):
if should_compact(messages):
messages = compact(messages)
messages.append(user_input())
response = model.call(messages=messages)
messages.append(response)
tool_results = execute_tools(response)
messages.extend(tool_results)
反模式三:忽略 token 成本
python
# 问题:每轮都重新读取整个项目配置
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()
# 后续轮次直接使用缓存
反模式四:上下文中的信息矛盾
这是最隐蔽也最危险的反模式。当上下文中包含互相矛盾的信息时,模型的行为会变得不可预测。
shell
# 场景:
# - CLAUDE.md 中写着"使用 ESLint 进行代码检查"
# - 第 5 轮的工具结果显示项目使用的是 Biome
# - 用户在第 8 轮说"用我们的 linter 检查一下"
# 模型可能会困惑:到底用 ESLint 还是 Biome?
修正方案:当发现矛盾时,Harness 应该主动更新或标注。最近的信息应该覆盖旧的信息,并且明确标记哪些是最新的。
反模式五:把上下文当"垃圾桶"
有些系统在每轮开始时都会注入大量的"以防万一"信息------完整的 API 文档、所有可能的错误码表、项目的全部文件列表。这种做法的逻辑是"宁可多了也不能少了",但实际效果恰恰相反。
信息过载会导致模型的"选择困难"。当上下文中有太多信息时,模型会在不同信息之间犹豫不决,输出质量反而下降。记住:少即是多,精即是准。
4.10 本章小结
上下文工程是 Agent 系统设计中最具杠杆效应的环节。它不像提示词工程那样有大量的"技巧集锦"可以速成,而是需要系统性的工程思维。
核心要点回顾:
-
区分 Prompt 和 Context。 提示词只是上下文的一小部分,上下文才是决定 Agent 行为的全部输入。
-
理解上下文的七大组成部分。 系统提示、工具定义、对话历史、记忆、检索文档、工具结果、用户消息------每一部分都有其特性和管理策略。
-
做好预算分配。 把上下文窗口当作有限的预算来管理,为每个组成部分设定合理的 token 额度。
-
实施压缩策略。 分层压缩、增量更新、结构化提取------确保上下文在整个会话生命周期中保持高信噪比。
-
避免反模式。 无限制的工具结果、缺失的压缩策略、忽略 token 成本、信息矛盾、信息过载------这些都是上下文工程的常见陷阱。
在下一章中,我们将深入探讨工具设计------Agent 通过工具与外部世界交互,工具的设计质量直接决定了 Agent 的能力上限。而工具的返回结果,正是本章讨论的上下文管理的重要对象。