Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
II 版 · Harness 工程 第 04 章 What the Model Gets to See
II · Harness 工程 04 / 22 进阶

Context Engineering

What the Model Gets to See
预计阅读 36 分钟
难度 进阶
关键词 上下文预算 · 上下文压缩
本机状态 未读

Prompt Engineering 关心「话怎么说」,Context Engineering 关心「给它看什么」。前者影响措辞,后者决定判断依据是否齐全。而在有成本约束的系统里,上下文还是一项必须做预算的资源——什么常驻、什么按需、什么必须压缩,这三件事构成效果与成本的分水岭。

编辑部注 · 本章是全站篇幅最长的一章,因为它同时影响效果上限与成本下限。建议读完后来回看两遍图 1。

一个被反复验证的现象:把提示词从「请仔细分析」改成更花哨的措辞,效果几乎不动;而把该给的数据补齐、把无关的噪音删掉,效果会明显变化。原因很朴素——模型只能基于看到的信息做判断,你不知道的信息它也不知道。

一、上下文的四种成分

把一次请求的上下文拆开,实际上只有四类内容。分清这四类,预算该给谁就有答案了。

成分内容变化频率建议策略
指令角色设定、行为约束、输出格式要求几乎不变常驻头部,保持字节稳定(前缀缓存友好)
能力声明工具定义、可用数据源说明很少变常驻,但要精简——工具描述本身占掉的 token 常被低估
知识检索到的资料、知识库片段、历史结论每轮可能变按需注入,必须带来源与相关性标注
状态已完成的步骤、当前进度、中间产物、错误记录每轮都变外部化 + 摘要,不要全量堆在上下文里

一个反直觉的观察:真正吃掉上下文的大头通常是「状态」而不是「知识」。检索来的资料一般几千 token,而十几轮之后的对话历史 + 工具返回结果可以轻松到几十万 token。

上下文预算:一张必须画的图 以 128k 窗口为例。关键不是「能装多少」,而是「装的东西有多少真正被用上」。 理想分工(按需增长) 指令 5% 能力声明 6% 按需注入的知识 18% 余量 71% 余量不是浪费,它是留给「长任务跑得比预期久」的空间,也是留给模型推理的余量。 没有治理时的现实(第 15 轮) 指令 2% 能力声明 3% 知识 10% 全量历史 + 工具返回值 85% → 真实判断依据被噪音稀释到 10%,成本却已经涨了十几倍。这就是「越跑越笨」的机制。 策略 ① 滚动窗口 只保留最近 N 轮,更早的直接丢 优点:实现最简单,成本可预测 缺点:早期关键约束被丢掉, 任务中段开始跑偏 适合:短任务、步骤间弱依赖 修补:把丢弃前的「结论」固化进 头部指令区,只丢过程不丢结论 推荐度:★★☆ 策略 ② 阶段摘要 把已完成的段落压缩成结构化摘要 优点:保住关键结论,成本可控 缺点:摘要本身要花钱、要设计, 且摘要有信息损失 适合:中长任务、阶段性明显 要求:摘要必须有固定字段 (目标 / 已完成 / 结论 / 待办) 推荐度:★★★(最常用) 策略 ③ 状态外置 中间产物写入文件,上下文只留指针 优点:上下文几乎不随轮次增长 缺点:需要文件系统与读写工具, 且模型要愿意去读 适合:长任务、大产出物 关键:文件名要能被模型读懂, 并在上下文里保留目录清单 推荐度:★★★(长任务必选)
图 1 上下文的「越跑越笨」不是模型退化,而是信噪比下降:真实判断依据的占比从 18% 被稀释到 10%,同时成本涨了十几倍。三种压缩策略要组合使用——短任务用滚动窗口,中长任务用阶段摘要,长任务必须叠加状态外置。

二、顺序会影响成本:前缀稳定性

在第 5 章(KV Cache)里我们已经知道了规则,这里给出它在 Harness 里的落地版排序原则:

上下文排列顺序(从上到下,稳定度递减)
位置放什么为什么放这里
① 最前系统指令、行为约束最稳定,几乎永不变化,缓存命中率最高
工具定义(顺序固定)变化少;必须保证序列化后字节稳定
长期知识 / 知识库摘要更新频率低(天级即可)
本任务的目标与约束一轮内不变
已完成步骤的结构化摘要每轮会变,但内容被压缩,增长可控
⑥ 最后本轮新信息(用户输入、最新工具结果)变化最频繁,放尾部让前面全部命中
一条简洁的纪律

把变化频率当成排序键:变化越少越靠前,变化越多越靠后。

这条规则同时满足两个目标——前缀缓存命中率最大化(省成本),以及「离问题近的信息更被关注」(提效果)。当你不确定某段内容该放哪时,问自己是「每轮都变」还是「几乎不变」就够了。

三、给知识加来源标注

检索到的内容如果不带来源,模型无从判断可信度,也无法在回答里给出可验证的引用。最小可行的做法是给每段知识打三个标签:

context_builder.pypython
from dataclasses import dataclass
import json, time
@dataclass
class KnowledgeChunk:
    source: str          # 来源标识(可点开的链接或文件路径)
    updated_at: str      # 更新时间:模型据此判断时效
    tier: str            # fact(可溯源事实)| inference(推断)| opinion(观点)
    text: str
    score: float = 0.0   # 相关性得分,用于排序与截断
def render_knowledge(chunks: list[KnowledgeChunk], budget_chars: int = 6000) -> str:
    """
    按相关性排序、按预算截断,并且**每一条都带来源与层级**。
    来源不是装饰,它是模型判断「该不该相信」的依据。
    """
    if not chunks:
        return "(本次未检索到相关资料。若信息不足,请明确说明而不是猜测。)"
    ordered = sorted(chunks, key=lambda c: -c.score)
    lines, used = [], 0
    for c in ordered:
        block = (f"[{c.tier}] {c.text.strip()}\n"
                 f"    来源:{c.source} 更新时间:{c.updated_at}")
        if used + len(block) > budget_chars:
            break                      # 硬性截断,防单条超长资料挤爆预算
        lines.append(block)
        used += len(block)
    dropped = len(ordered) - len(lines)
    tail = f"\n(另有 {dropped} 条相关资料因预算限制未纳入。)" if dropped else ""
    return "\n\n".join(lines) + tail
def build_messages(
    instructions: str,
    tool_defs: list[dict],
    task: str,
    progress_summary: str,
    knowledge: list[KnowledgeChunk],
    new_input: str,
) -> list[dict]:
    """
    严格按「稳定度递减」排列,保证前缀缓存命中率最大化。
    注意:instructions 与 tool_defs 里绝对不能出现时间戳等动态内容。
    """
    stable_head = json.dumps(tool_defs, ensure_ascii=False, sort_keys=True)
    return [
        {"role": "system", "content": instructions},                     # ① 最稳定
        {"role": "system", "content": f"可用工具:\n{stable_head}"},      # ② 顺序固定
        {"role": "user", "content": f"任务目标:{task}"},                 # ④ 一轮内不变
        {"role": "user", "content": f"已完成步骤:\n{progress_summary}"},  # ⑤ 压缩后
        {"role": "user", "content": f"参考资料:\n{render_knowledge(knowledge)}"},  # ③/⑤
        {"role": "user", "content": new_input},                          # ⑥ 变化最频繁,放最后
    ]
def summarize_progress(steps: list[dict]) -> str:
    """
    阶段摘要必须结构化。自由文本摘要会越摘越糊。
    固定四段:目标 / 已完成 / 关键结论 / 待办
    """
    done = [s for s in steps if s.get("status") == "ok"]
    failed = [s for s in steps if s.get("status") not in ("ok", None)]
    return (
        f"· 已完成({len(done)} 步):"
        + ";".join(f"{s['tool']}({s.get('brief','')})" for s in done[-8:])
        + f"\n· 失败/放弃({len(failed)} 步):"
        + ";".join(f"{s['tool']}{s.get('status')}" for s in failed[-5:])
        + "\n· 待办:见任务目标未覆盖的部分"
    )

四、什么时候该压缩:三个触发信号

不要等上下文满了才压——那时候往往已经来不及(压缩本身要花一次模型调用,而你已经没有余量了)。三个提前量信号:

信号阈值建议动作
用量占比达到窗口的 60%触发阶段摘要,把最早的一批步骤压成结构化条目
单条超长任一工具返回超过窗口的 10%立即做「头尾保留 + 中间省略」的裁剪,并标注省略了多少字符
阶段边界一个子任务完成时主动固化结论、清理过程性内容(如中间试错的输出)
实操任务
  • 给你的 Harness 加一个「上下文占用监控」:每轮记录 tokens_used / window,跑一个长任务画出曲线;
  • 对比「60% 触发压缩」与「90% 触发压缩」两种策略下的任务完成率与总成本;
  • 写一段脚本验证前缀稳定性:连续两轮请求的字节前缀,第一个不同的位置应该出现在 90% 之后。

验收标准:能说出你的系统在 128k 窗口下、第 20 轮时的上下文构成(四类成分各占多少),并说明压缩在什么位置触发。

五、自测

本章自测第 3 题为面试高频题
Q1上下文里通常吃掉最多 token 的是哪一类?
B。这是很实用的一个观察:知识检索一次通常几千 token,而十几轮之后的历史与工具返回值能到几十万 token。所以要优先治理的是「状态」而不是「知识」——把中间产物外部化、把历史结构化压缩,比优化检索的收益更直接。
Q2上下文各部分应该按什么顺序排列?
D。这条规则同时优化两个目标:前缀缓存命中率最大(变化少的在前 → 每轮共享最长前缀),以及信息相关性(新信息在尾部,离生成位置更近)。注意 A 看起来合理但会破坏缓存——把动态内容放前面,每轮前缀全废。
Q3为什么给检索到的知识标注「来源」和「层级(事实/推断/观点)」很重要?
A。没有来源和层级,模型只能把所有检索内容一视同仁——一条用户随口说的「观点」和一条系统记录的事实会被同等采信。标注层级本质上是把「可信度判断」这个能力所需的输入补给模型,同时让最终回答可以带上可点开的引用,这是治理幻觉最有效的手段之一。

六、小结

议题结论
上下文四成分类指令 / 能力声明 / 知识 / 状态;治理重点在「状态」
排序原则按变化频率,稳定的靠前,动态的靠后
压缩策略滚动窗口(短任务)+ 阶段摘要(通用)+ 状态外置(长任务)
压缩触发用量 60%、单条超 10%、阶段边界,三个提前量信号
知识注入必须带来源、更新时间、可信层级,否则无法判断与引用

效果上限不是由提示词的精妙程度决定的,而是由「该给的信息有没有给、不该给的噪音有没有删」决定的。本刊编辑部

下一章处理一个更长期的问题:如果这些信息不该每一轮都重新塞进去,那该存在哪里、什么时候取出来。

面试官会怎么问

共 4 条 · 其中 3 条高频 · 先自己答一遍,再展开对照

Prompt Engineering 和 Context Engineering 的区别是什么? 高频
一个很实用的划分:Prompt Engineering 解决"怎么说",Context Engineering 解决"给它看什么"
前者是措辞、格式、few-shot 的组织、角色与约束的表达;后者是决定模型的可见信息集合——召回哪些内容、裁剪掉哪些、什么时候注入记忆、历史压缩到什么粒度、静态与动态部分怎么排列。
工程上后者的权重更大,因为它决定了效果上限:提示词写得再好,如果关键信息根本没进上下文,模型只能猜。而且 Context Engineering 直接决定成本(token 量、缓存命中率)与延迟,是可量化、可优化的部分。
Schema 有几百个字段,你会全塞进 prompt 吗? 高频
不会。全塞进去有三个问题:token 成本高、字段之间互相干扰导致选错、以及破坏前缀稳定性(每次召回的字段集不同,缓存全废)。
分层做法:
① 常驻层——工具名、少量最高频字段、以及"如何获取字段清单"的元工具(让模型能主动查);
② 按需召回层——先用一次轻量检索(关键词或向量)从字段库里召回候选字段子集,再放进上下文;
③ 外置层——完整 schema 放在可查询的资源里,模型需要时通过工具查全量定义。
更稳的做法是两阶段:先让模型输出"我要用到哪些字段",程序校验后把对应 schema 注入,再让它生成最终条件。这样既省 token,也让每一步的生成空间可控、可校验。

追问链

  1. 那召回错了怎么办?
  2. 这个两阶段设计会多花一轮,成本上划算吗?
加分点:主动算一下"多一轮调用"与"塞满 schema"的成本对比,说明你是按账算的。
上下文快满了,你怎么知道?压缩策略是什么? 高频
第一件事是主动计量,而不是等 API 报错。每次组装上下文前精确算 token(含工具 schema 与预留的输出预算),维护一个"预算水位"。触发阈值通常设在 70%–80%,给自己留出压缩和重试的余量。
压缩按"损伤从小到大"分三级:
① 裁剪——工具结果只保留关键字段,丢弃冗长的原始响应、日志、重复内容。这一级几乎无损,应该常态化执行;
② 摘要化——把早期历史替换成摘要,只保留决策与结论,丢掉推理过程;
③ 外置——把大块内容(长文档、大 JSON、已完成产物)落盘,上下文里只留指针(路径、ID、摘要),需要时通过工具回读。
关键设计是摘要里必须保留指针,否则信息真丢了。压缩要记日志、最好对用户可见,并且可回滚。
加分点:强调"摘要保留指针 + 压缩可回滚",是从"能跑"到"能上生产"的差别。
为什么上下文的排列顺序会影响成本?
因为缓存是前缀匹配的。缓存从第一个 token 开始比对,遇到第一个不同点,之后的全部失效。所以排列顺序直接决定命中率:
稳定内容放前面(静态 system prompt、工具列表按固定顺序、few-shot 固定不变),动态内容放后面(当前时间、用户信息、本轮检索结果、新增历史)。
常见的反面写法很隐蔽:把当前时间戳放在 system prompt 开头、每次按使用频率重排工具列表、把召回片段按相关度排序(顺序每次不同)、在开头注入会话 ID。这些都会让整段缓存作废,成本可能差好几倍,而日志上完全看不出来——只能靠监控命中率发现。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes