Skip to content

上下文工程的工程实践 ​

标签
AI/agent/上下文
字数
5269 字
阅读时间
21 分钟

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  兜底压缩,且要先保住结构
   不超限直接返回;超限时按分区逐个决策,而不是简单砍尾巴

设计上刻意做了减法:不引入来源 / 优先级等分类维度,
统一用「相关性 + 新近性」一个分数选择。

两个数据结构 ​

python
@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:多源汇集,容错优先 ​

python
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:先扣系统指令的预算,再贪心填充 ​

python
# 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:按类型分组,拼成固定分区 ​

python
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:兜底压缩,且要先保住结构 ​

python
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 找到最优配置——这两个权重是这套系统里最需要按数据调的值

相关 ​

参考 ​

  • 《Hello-Agents》第九章 §9.2–§9.3

贡献者 ​

文件历史 ​