上下文工程的工程实践
Context Engineering 那篇讲的是这一层的概念——术语来源、四个核心操作、六个信息源、context rot。这一篇讲落地:具体怎么把高信号密度的 token 组装起来。
一句话目标:用尽可能少、但高信号密度的 token,最大化获得期望结果的概率。
有效上下文的三个组件
系统提示
两个常见误区,方向相反:
| 误区 | 后果 |
|---|---|
| 过度硬编码 | 在提示里写复杂、脆弱的 if-else 逻辑,长期维护成本高、易碎 |
| 过于空泛 | 只给宏观目标与泛化指引,缺少对期望输出的具体信号,或者假定了不存在的「共享上下文」 |
做法是分区组织(<background_information>、<instructions>、工具指引、输出描述等),用 XML 或 Markdown 分隔。追求的目标是能完整勾勒期望行为的「最小必要信息集」——注意「最小」不等于「最短」。
调优顺序:先用最强的模型在最小提示上试跑,再按失败模式增补指令与示例。反过来(一上来就写满)会掩盖掉真正的必需信息是哪些。
工具
工具定义了智能体与信息/行动空间的契约,三条要求:
- 职责单一、相互低重叠,接口语义清晰
- 对错误鲁棒
- 入参描述明确无歧义
常见失败模式是臃肿工具集:功能边界模糊,导致「选哪个工具」这个决策本身就含混。判据很直接——如果人类工程师都说不准该用哪个工具,别指望智能体做得更好。甄别出一个「最小可行工具集(MVTS)」通常能显著提升长期交互的稳定性。
示例
始终推荐给示例,但不要把所有边界条件一股脑塞进提示。精选一组多样且典型的示例去「画像」期望行为。
从预计算检索到 JIT 上下文
一个简洁定义:智能体 = 在循环中自主调用工具的 LLM。
工程实践正在从「推理前一次性 embedding 检索」转向及时(Just-in-time, JIT)上下文:
| 预计算检索 | JIT | |
|---|---|---|
| 做法 | 提前把相关数据加载进上下文 | 只维护轻量引用(文件路径、存储查询、URL),运行时按需加载 |
| 成本 | 索引成本高,可能过时 | 无索引,实时 |
| 手法 | embedding 相似度 | 让模型自己写查询、缓存结果、用 head/tail 分析大体量数据 |
认知模式更接近人:我们不死记全部信息,而是靠文件系统、收件箱、书签这些外部索引按需提取。
引用的元数据本身也在传递信息。目录层级、命名约定、时间戳都在隐含地说明「目的与时效」——tests/test_utils.py 和 src/core/test_utils.py 的语义暗示就不一样。
允许自主检索还带来渐进式披露(progressive disclosure):每一步交互产生新上下文,反过来指导下一步决策——文件大小暗示复杂度、命名暗示用途、时间戳暗示相关性。智能体按层构建理解,工作记忆里只留「当前必要子集」。
代价要认:运行时探索比预计算检索慢,且必须有「主见」的工程设计(正确的工具 + 启发式)。缺引导时,智能体会误用工具、追死胡同、错过关键信息,把上下文浪费掉。
多数场景下混合策略更有效:前置加载少量高价值上下文保证速度,再让智能体按需探索。典型的工程做法是预置 README / 项目约定文件,同时提供 glob、grep 这类原语——绕开过时索引和复杂语法树的沉没成本。
工程实践正在从「推理前一次性检索」转向 JIT 上下文。
预计算检索 JIT(及时上下文)
做法 提前把相关数据加载进上下文 只维护轻量引用(文件路径 /
存储查询 / URL),运行时按需加载
成本 索引成本高,可能过时 无索引,实时
手法 embedding 相似度 让模型自己写查询、缓存结果、
用 head / tail 分析大体量数据
认知模式更接近人:我们不死记全部信息,
而是靠文件系统、收件箱、书签这些外部索引按需提取。
引用的元数据本身也在传递信息
目录层级、命名约定、时间戳都在隐含地说明「目的与时效」
└─ tests/test_utils.py 与 src/core/test_utils.py 的语义暗示就不一样
允许自主检索还带来渐进式披露(progressive disclosure)
每一步交互产生新上下文,反过来指导下一步决策:
文件大小暗示复杂度、命名暗示用途、时间戳暗示相关性
└─ 智能体按层构建理解,工作记忆里只留「当前必要子集」
代价要认
运行时探索比预计算检索慢,且必须有「主见」的工程设计
(正确的工具 + 启发式)
缺引导时,智能体会误用工具、追死胡同、错过关键信息,把上下文浪费掉
多数场景下混合策略更有效
前置加载少量高价值上下文保证速度,再让智能体按需探索
└─ 典型做法:预置 README / 项目约定文件,
同时提供 glob、grep 这类原语 ——
绕开过时索引和复杂语法树的沉没成本长时程任务的三种手段
长时程任务(大型代码库迁移、跨数小时的研究)要求智能体在超出窗口的长序列中保持连贯。指望更大的窗口不能根治上下文污染与相关性退化,需要三种针对性的工程手段。
| 手段 | 做法 | 适合 |
|---|---|---|
| 压缩整合(Compaction) | 接近上限时做高保真总结,用摘要重启新窗口 | 需要长对话连续性,强调上下文的「接力」 |
| 结构化笔记(Structured note-taking) | 以固定频率把关键信息写进上下文外的持久化存储,后续按需拉回 | 有里程碑/阶段性成果的迭代式开发与研究 |
| 子代理架构(Sub-agent) | 主代理负责高层规划与综合,多个专长子代理在干净窗口里各自深挖,最后只回传凝练摘要(常见 1000–2000 tokens) | 复杂研究与分析,能从并行探索获益 |
Compaction 的实现细节值得单独记:压缩时保留架构性决策、未解决缺陷、实现细节,丢弃重复的工具输出与噪声;新窗口携带「压缩摘要 + 最近少量高相关工件」。调参顺序是先优化召回(不遗漏关键信息),再优化精确度(剔除冗余)——反过来的话会先把关键信息丢掉。一种安全的「轻触式」压缩是只清理深历史里的工具调用与结果。
子代理架构的价值是关注点分离:庞杂的搜索上下文留在子代理内部,主代理只面对摘要。
长时程任务的三种手段,各自丢与留的东西不同。
┌──────────────────────────────────────────────────────────┐
│ 压缩整合 Compaction │
│ 接近上限时做高保真总结,用摘要重启新窗口 │
│ 保留:架构性决策 / 未解决缺陷 / 实现细节 │
│ 丢弃:重复的工具输出与噪声 │
│ 新窗口携带:压缩摘要 + 最近少量高相关工件 │
│ 调参顺序:先优化召回(不遗漏关键信息),再优化精确度(剔冗余)│
│ └─ 反过来会先把关键信息丢掉 │
│ 适合:需要长对话连续性,强调上下文的「接力」 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 结构化笔记 Structured note-taking │
│ 以固定频率把关键信息写进上下文外的持久化存储,后续按需拉回 │
│ 适合:有里程碑 / 阶段性成果的迭代式开发与研究 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 子代理架构 Sub-agent │
│ 主代理负责高层规划与综合,多个专长子代理在干净窗口里各自深挖, │
│ 最后只回传凝练摘要(常见 1000–2000 tokens) │
│ 价值是关注点分离:庞杂的搜索上下文留在子代理内部 │
│ 适合:复杂研究与分析,能从并行探索获益 │
└──────────────────────────────────────────────────────────┘
前提判断:指望更大的窗口不能根治上下文污染与相关性退化,
所以需要这三种针对性的工程手段。
分工:compaction 丢细节换空间;结构化笔记保留细节但延迟加载;
子代理把探索过程整体隔离出去。GSSC 流水线
HelloAgents 把上下文构建抽象成四阶段流水线:Gather → Select → Structure → Compress。
设计上刻意做了减法:不引入来源/优先级等分类维度,统一用「相关性 + 新近性」一个分数选择。该文的说法是,实践表明这套简单评分在大多数场景已经够用。
GSSC 四阶段流水线:Gather → Select → Structure → Compress。
① Gather 多源汇集,容错优先
系统指令(最高优先级,不参与评分)
记忆系统检索(limit=10, min_importance=0.3)
RAG 检索(limit=5, min_score=0.3)
对话历史(只保留最近 5 条,基础相关性 0.6)
自定义信息包
└─ 每个外部数据源都包在 try-except 里:
单个源失败不影响整体,打 WARNING 继续
│
▼
② Select 先扣系统指令的预算,再贪心填充
分离系统指令 → 系统指令先占预算 → 其他算综合分数 → 降序贪心填充
└─ 按分数降序的贪心 + 满了就 break,不是背包优化
代价:可能因为一条大包占位而浪费剩余空间
│
▼
③ Structure 按类型分组,拼成固定分区
│
▼
④ Compress 兜底压缩,且要先保住结构
不超限直接返回;超限时按分区逐个决策,而不是简单砍尾巴
设计上刻意做了减法:不引入来源 / 优先级等分类维度,
统一用「相关性 + 新近性」一个分数选择。两个数据结构
@dataclass
class ContextPacket:
content: str
timestamp: datetime
token_count: int
relevance_score: float = 0.5 # 构造时被夹到 [0, 1]
metadata: Optional[Dict[str, Any]] = None
@dataclass
class ContextConfig:
max_tokens: int = 3000
reserve_ratio: float = 0.2 # 为系统指令预留的比例
min_relevance: float = 0.1
enable_compression: bool = True
recency_weight: float = 0.3
relevance_weight: float = 0.7
def __post_init__(self):
assert abs(self.recency_weight + self.relevance_weight - 1.0) < 1e-6两个断言不是形式主义:权重之和必须为 1 保证综合分数的量纲稳定;reserve_ratio 是给系统指令的保险,避免它被其他信息挤掉。
Gather:多源汇集,容错优先
packets = []
# 1. 系统指令(最高优先级,不参与评分)
packets.append(ContextPacket(content=system_instructions, relevance_score=1.0,
metadata={"type": "system_instruction", "priority": "high"}))
# 2. 记忆系统检索(limit=10, min_importance=0.3)
# 3. RAG 检索(limit=5, min_score=0.3)
# 4. 对话历史(只保留最近 5 条,基础相关性 0.6)
# 5. 自定义信息包三处设计考虑:每个外部数据源都包在 try-except 里(单个源失败不影响整体,打印 WARNING 继续);系统指令 relevance_score 直接给 1.0 且打上 type 标记,后续阶段单独处理;对话历史硬限制为最近 5 条。
Select:先扣系统指令的预算,再贪心填充
# 1. 分离系统指令与其他
system_packets = [p for p in packets if p.metadata.get("type") == "system_instruction"]
other_packets = [p for p in packets if p.metadata.get("type") != "system_instruction"]
# 2. 系统指令先扣预算
remaining_tokens = available_tokens - sum(p.token_count for p in system_packets)
if remaining_tokens <= 0:
print("[WARNING] 系统指令已占满所有 token 预算")
return system_packets
# 3. 其他信息算综合分数
combined_score = self.config.relevance_weight * packet.relevance_score \
+ self.config.recency_weight * recency
if packet.relevance_score >= self.config.min_relevance:
scored_packets.append((combined_score, packet))
# 4. 降序排序 → 贪心填充到上限
for score, packet in scored_packets:
if current_tokens + packet.token_count <= available_tokens:
selected.append(packet)
else:
break两个必须注意的点:
预算扣减发生在评分之前。系统指令不参与评分,但它先占掉自己的那份,remaining_tokens 才是其他信息能用的空间。顺序反了会让系统指令被挤掉。
过滤用的是 relevance_score,排序用的是 combined_score。两者不能混——用综合分数做阈值过滤的话,一条很新但完全不相关的信息能越过滤网。
填充策略是按分数降序的贪心 + 满了就 break,不是背包优化。这样实现简单,代价是可能因为一条大包占位而浪费剩余空间。
Select 阶段有两个顺序约束,弄反了会静默失效。
① 预算扣减必须发生在评分之前
system_packets = 按 type 分离出的系统指令
other_packets = 其余
remaining_tokens = available_tokens − sum(system_packets 的 token)
│
└─ 系统指令不参与评分,但它先占掉自己的那一份,
remaining_tokens 才是其他信息能用的空间
顺序反了 ──▶ 系统指令会被挤掉
② 过滤用 relevance_score,排序用 combined_score
combined_score = relevance_weight × relevance_score
+ recency_weight × recency
├─ 过滤(阈值 min_relevance = 0.1)只看相关性的 relevance_score
└─ 排序用两者加权的 combined_score
└─ 两者不能混:用综合分数做阈值过滤的话,
一条很新但完全不相关的信息能越过滤网
两个数据结构里的断言不是形式主义
recency_weight + relevance_weight 必须为 1 ──▶ 保证综合分数的量纲稳定
reserve_ratio(默认 0.2)是给系统指令的保险,避免它被其他信息挤掉Structure:按类型分组,拼成固定分区
def _structure(self, selected_packets, user_query):
# 按类型分组
system_instructions, evidence, context = [], [], []
for packet in selected_packets:
t = packet.metadata.get("type", "general")
if t == "system_instruction":
system_instructions.append(packet.content)
elif t in ["rag_result", "knowledge"]:
evidence.append(packet.content)
else:
context.append(packet.content)
sections = []
if system_instructions:
sections.append("[Role & Policies]\n" + "\n".join(system_instructions))
sections.append(f"[Task]\n{user_query}")
if evidence:
sections.append("[Evidence]\n" + "\n---\n".join(evidence))
if context:
sections.append("[Context]\n" + "\n".join(context))
sections.append("[Output]\n请基于以上信息,提供准确、有据的回答。")
return "\n\n".join(sections)三个细节值得注意:
[Task] 和 [Output] 是无条件追加的,其余分区是「有才追加」。 这保证任何情况下都有一份可用的骨架——任务和输出要求不会因为某个信息源为空而消失。
分组规则是三路 if/elif/else:系统指令一类、rag_result 与 knowledge 归证据、其余全部落进 [Context]。这个 else 是兜底——没有它,新增一种 type 的信息包会被静默丢掉。
分隔符不同:[Evidence] 内部用 "\n---\n"(多份证据之间要有明显分隔),[Context] 用 "\n" 直接连接。证据是并列的独立条目,上下文是连续的叙事——分隔强度反映了两种内容的性质差异。
三个优势:可读性(人和模型都更容易理解结构)、可调试性(能快速定位是哪个区域的信息有问题)、可扩展性(加新信息源只需要创建新分区)。
**设计文档与实际实现有一处不一致。** 原书 §9.3.1 在介绍设计目标时列了**六个分区**(含 `[State]`),但 §9.3.3 的 `_structure` 实现只拼出**五个**——`[State]` 没有对应的代码分支。这一处未在材料中说明,标为存疑:可能是设计文档先行、实现滞后,也可能 `[State]` 的内容被归并进了 `[Context]`。
Structure 拼出固定分区,哪些无条件、哪些「有才追加」是刻意的。
┌──────────────────────────────────────────────────────────┐
│ [Role & Policies] 系统指令 有才追加 │
├──────────────────────────────────────────────────────────┤
│ [Task] user_query 无条件追加 ← │
├──────────────────────────────────────────────────────────┤
│ [Evidence] rag_result / 有才追加 │
│ knowledge 类 │
│ └─ 内部用 "\n---\n" 分隔 │
├──────────────────────────────────────────────────────────┤
│ [Context] 其余全部落进这里 有才追加 │
│ └─ 用 "\n" 直接连接 │
├──────────────────────────────────────────────────────────┤
│ [Output] 输出要求 无条件追加 ← │
└──────────────────────────────────────────────────────────┘
[Task] 与 [Output] 无条件追加,保证任何情况下都有一份可用的骨架 ——
任务和输出要求不会因为某个信息源为空而消失。
两个细节
分组规则是三路 if / elif / else:系统指令一类、rag_result 与 knowledge 归证据、
其余全落进 [Context]。
└─ 那个 else 是兜底 —— 没有它,新增一种 type 的信息包会被静默丢掉
分隔符强度反映内容性质:
证据是并列的独立条目 ──▶ "\n---\n"(明显分隔)
上下文是连续的叙事 ──▶ "\n"(直接连接)
三个优势:可读性 / 可调试性(能快速定位是哪个区域的信息有问题)/ 可扩展性。Compress:兜底压缩,且要先保住结构
def _compress(self, context: str, max_tokens: int) -> str:
current_tokens = self._count_tokens(context)
if current_tokens <= max_tokens:
return context # 不超限直接返回,不做任何处理
sections = context.split("\n\n") # 按分区切分
compressed_sections, current_total = [], 0
for section in sections:
section_tokens = self._count_tokens(section)
if current_total + section_tokens <= max_tokens:
compressed_sections.append(section) # 完整保留
current_total += section_tokens
else:
remaining = max_tokens - current_total
if remaining > 50: # 至少保留 50 tokens
compressed_sections.append(
self._truncate_text(section, remaining) + "\n[... 内容已压缩 ...]")
break
return "\n\n".join(compressed_sections)核心原则是「保持结构完整性」——即使在预算紧张时也按分区逐个决策,而不是简单砍尾巴。代价是最后一个分区被截断,但前面的分区是完整的。
四个设计点:
| 点 | 说明 |
|---|---|
| 不超限就直返 | 压缩只在触发时发生,不是每次都跑 |
按分区切分(split("\n\n")) | 切分依据正是 Structure 阶段的分区间隔——这两个阶段是配套设计的 |
| 50 token 下限 | 剩得太少就不截了,截一半的表头反而比没有更误导 |
| 截断处加标记 | [... 内容已压缩 ...]——让模型知道这里信息不完整,这也是 RAG 里「只依据上下文回答、不足时说不确定」能生效的前提 |
两处「生产环境应该…」的边界
原代码里有两处注释明确标出了教学实现与生产实现的差距,这两处正好是这一层最容易偷工减料的地方:
| 位置 | 教学实现 | 生产该怎么做 | 为什么重要 |
|---|---|---|---|
_count_tokens | 启发式估算:中文 1 字符 ≈ 1 token,英文 1 单词 ≈ 1.3 token | 实际的 tokenizer | 估算偏差会直接让「预算守护」失效——你以为在 3000 以内,实际可能超了。分词计入方式见 05-文本分词与子词算法:BPE、WordPiece 与 Unigram |
_calculate_relevance | 关键词重叠 | 向量相似度 | 关键词重叠抓不到同义改写;换成向量后这一层就接上了 12-向量检索与 ANN 索引 |
_truncate_text 还用了一个更粗的近似:按 len(text) / token_count 算出的字符/token 比例来折算。这个比例本身依赖前一个估算函数,两处误差会叠加。
五条最佳实践
| 实践 | 做法 |
|---|---|
| 动态调整 token 预算 | 按任务复杂度调 max_tokens——简单任务给小预算,复杂任务加大 |
| 相关性计算优化 | 生产环境把关键词重叠换成向量相似度 |
| 缓存 | 对不变的系统指令和知识库内容做缓存,避免重复计算。这与 [[18-Context Engineering |
| 监控与日志 | 记录每次构建的统计(选中信息数量、token 使用率),作为后续优化的依据 |
| A/B 测试 | 对关键参数(相关性权重、新近性权重)用 A/B 找到最优配置——这两个权重是这套系统里最需要按数据调的值 |
相关
- Context Engineering —— 这一层的概念:四个核心操作、六个信息源、context rot
- 20-上下文工具:NoteTool 与 TerminalTool —— 承载结构化笔记与 JIT 访问的两个具体工具
- 10-智能体记忆系统 —— Gather 阶段的一个数据源
参考
- 《Hello-Agents》第九章 §9.2–§9.3
YJ