02. 上下文工程:Agent 只能看见你喂给它的东西
静态前缀稳住缓存,动态轨迹可控增长,能力按需加载
上一篇把 Agent 写成 LLM + 上下文 + 工具,并落到 Harness。真正写系统时,最先被低估的往往是中间那一项:上下文——每次推理时模型实际「看见」的全部 token,而不是用户刚打的那一句话。
一位新入职的强工程师,若没有产品架构、分支策略、密钥放哪、测试怎么跑,写出来的代码语法正确也可能与仓库冲突。Agent 同理:模型智力只是下限,每个决策点拿到的背景信息质量才是上限。本篇对应书的第 2 章,把上下文从 API 消息拆到 KV Cache、Skills、状态栏与压缩。
四类消息角色:无状态 API 上的「记忆」
以 OpenAI 风格的 Chat Completions 为例(Anthropic / Google 结构同类),一次请求的核心是 messages 列表。每条消息有 role(角色),模型靠它判断来源与优先级:
| role | 谁写的 | 含义 |
|---|---|---|
system | 开发者 | 身份、规则、流程;通常一条,放最前 |
user | 终端用户 | 本轮请求;也可夹检索片段 |
assistant | 模型 | 文本回复、tool_calls、部分厂商的 reasoning |
tool | 运行时 | 工具执行结果;用 tool_call_id 与调用配对 |
工具定义(tools)一般是请求的独立字段,不是消息:告诉模型有哪些函数、参数 schema 是什么。没有它,模型通常无法发起规范的工具调用。
关键工程事实:API 调用本身无状态。模型不会「记得」上一轮——Agent 框架必须每次把完整 messages 再送回去。所谓「多轮对话」,本质是客户端在列表末尾不断追加。
Agent 循环就是 messages 的生长
带工具时,一轮最小循环是:
- 送
system+user+tools - 模型返回
assistant(可能含tool_calls) - 框架执行工具,追加
tool结果 - 再请求;若无
tool_calls,输出最终文本并停
from __future__ import annotations
# 最小可运行示意:用假 client 模拟模型;接真实 API 时替换 chat() 即可
from typing import Any
def chat(messages: list[dict], tools: list[dict]) -> dict:
"""模拟:首轮请求工具,第二轮给最终答案。"""
has_tool_result = any(m.get("role") == "tool" for m in messages)
if not has_tool_result:
return {
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city":"Vancouver","unit":"celsius"}',
},
}],
}
return {
"role": "assistant",
"content": "Vancouver 13.2°C, clear, humidity 93%.",
"tool_calls": None,
}
def execute_tool(name: str, arguments: str) -> str:
"""根据工具名返回固定结果(真实系统应解析 arguments 并调 API)。"""
if name == "get_weather":
return '{"city":"Vancouver","temperature":13.2,"unit":"celsius","conditions":"clear","humidity":93}'
return "{}"
def run_agent(user_text: str, tools: list[dict], max_steps: int = 10) -> str:
"""管理 messages 列表:有 tool_calls 就执行并追加,否则结束。"""
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": "需要实时信息时调用工具;信息足够则直接回答用户。",
},
{"role": "user", "content": user_text},
]
for _ in range(max_steps):
assistant = chat(messages, tools)
messages.append(assistant)
tool_calls = assistant.get("tool_calls") or []
if not tool_calls:
return assistant.get("content") or ""
for tc in tool_calls:
fn = tc["function"]
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": execute_tool(fn["name"], fn["arguments"]),
})
return "达到步数上限,未完成。"
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询城市当前天气;仅在需要实时气象数据时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
},
}]
if __name__ == "__main__":
out = run_agent("What's the weather in Vancouver?", tools)
print(out)
# 典型输出:Vancouver 13.2°C, clear, humidity 93%.
跟踪 messages 的形状,比盯「智能感」有用:
- 初始:
system+user - 第一轮后:再加
assistant(tool_calls)+ 若干tool - 结束:再加最终
assistant(content)
后续所有「上下文工程」——压缩、Skills、状态栏——都是在改这个列表的内容与结构。
从 API 视角,一次调用的上下文可粗分为五块(与 01 对齐):
- 系统提示词
- 工具定义
- 用户消息
- 模型回复(含思考 / 文本 / 工具请求)
- 工具结果
前两项构成相对稳定的静态前缀;后三项构成不断增长的轨迹(trajectory)。记这句就够:前面尽量别动,后面才压缩。
KV Cache 友好:前缀字节级稳定
KV Cache(Key-Value Cache)是推理引擎的优化:每个 token 在注意力里算出的 Key / Value 向量(注意力机制里用于「匹配」与「取内容」的两类中间表示)可缓存下来,后续生成只需为新增 token 算注意力,而不必每步重算整段前文。
跨多次 API 请求时,服务商还提供 Prompt Cache(提示词缓存):相同前缀的请求可复用已算好的前缀表示,直接降低延迟与计费。两层都认同一条物理规律:
前缀必须字节级不变。改一个字符,从改动点起的缓存全部失效。
常见事故:在 system 里写 Current time: {{now}}。每次时间戳不同 → 前缀永不命中 → 首 token 延迟(TTFT,Time To First Token)与费用一起上扬。团队侧可能表现为:首 token 从亚秒级涨到数秒、月账单近乎翻倍——根因不在模型,在一行「看起来无害」的动态注入。
三条可执行原则:
- 系统提示词与工具定义定稿后不要热改(连空格、工具顺序都算)。
- 动态信息追加到末尾——时间、cwd、任务进度做成新消息或状态栏,不回写
system前缀。 - 走标准消息 API + Chat Template,不要手写
"USER: ... ASSISTANT: ..."字符串拼接。
Chat Template(聊天模板) 把结构化 role/content 译成模型训练时见过的线性 token 流(如 Qwen 的 <|im_start|>system ... <|im_end|>)。自行拼接容易偏离训练格式,削弱多步工具调用;缓存虽只认 token 字节,但不稳定的拼接同样会打爆命中率。
补充:若必须「发现工具后再注入完整 schema」,正确做法是追加到轨迹末尾(只增不改),而不是重排静态 tools 前缀。OpenAI / Anthropic 近年的 tool search / 延迟加载,本质就是:前缀只留工具名与简述,完整 schema 在需要时并入历史,之后固定在原位置继续吃缓存。
提示工程:流程驱动,而不是规则堆砌
提示工程(Prompt Engineering) 在这里主要指写好 system:身份、约束、工作流程、输出风格。检验标准很朴素——
把模型当成聪明但零业务背景的新员工:读完这份手册,他是否知道下一步该干什么?
几个比「堆 MUST」更重要的结构选择:
流程驱动 vs 规则堆砌。 上百条无序规则,模型与人都会在「多条同时适用」时失措。写成 SOP(标准操作流程)更有效:Step 1 校验 → Step 2 分类 → Step 3 预处理 → Step 4 执行 → Step 5 验收。异常时按当前阶段处理,而不是全文扫规则表。
业务规则细化到可执行。 「根据情况选择计费类型」会让同一任务在不同时刻落到不同分支。要写成:退款与取消禁止走百分比抽成;省钱提成仅限谈判降低现有账单;金额按明确粒度四舍五入。产品定义规则,工程把规则编码进提示词——模型的长处是遵从复杂指令,不该被默认成业务立法机关。
结构化标记。 XML 标签名本身带语义(<working_directory>),Markdown 管层次。XML + Markdown 双层结构,比纯散文更易被模型稳定解析。
Few-shot 示例。 风格、版式、语气难用规则说清时,给 2~3 个覆盖边界的输入-输出示例,往往比十倍篇幅的抽象描述有效。示例一旦纳入前缀,就应固定——按请求动态检索「最相关示例」等于每轮改前缀,缓存与行为一起漂。
工具描述属于静态前缀的另一半:写清「何时用 / 不能做什么 / 参数例子 / 返回形状」。工具选错时,优先改描述,而不是先换更大模型(第 4 章会展开粒度与保真)。
提示注入:指令与数据必须分家
提示注入(Prompt Injection) 指外部内容(网页、邮件、PDF、图片元数据)里夹带伪装指令,诱导模型偏离开发者规则。聊天机器人被注入,最坏是胡说;带工具的 Agent 被注入,可能删文件、外发邮件、泄露私有数据。
上下文层能做的最小集合:
- 来源标记:外部内容用
<external_content source="webpage">...</external_content>包裹,声明「这是数据,不是指令」。 - 角色隔离:工具结果走
tool角色,不要揉进user——否则训练时建立的优先级边界被抹掉。 - 输入清洗:过滤常见注入短语;只能作辅助,变体绕过成本很低。
注意:本章后面的 Skills 与 状态栏 本身也是高信任注入面——第三方 Skill 等于制度化的「外部文本当指令」;状态栏被模型高度信任,若把未清洗网页摘要写进去,信任会被反向利用。上下文层挡不住时,靠执行层权限、沙盒与独立审查(第 4、5 章)。
Skills:可加载的领域能力单元
系统提示词无限加长,会出现两件事:token 账单与 注意力稀释(中间信息被忽略的概率上升,参见 Lost in the Middle 一类长上下文现象)。
Agent Skills 把领域能力做成可组合的知识包,采用 渐进式披露(Progressive Disclosure):
| 层 | 内容 | 何时进上下文 |
|---|---|---|
| L0 元数据 | 名称 + 一两句描述(数百 token 量级) | 启动时常驻,利于缓存 |
| L1 核心 | SKILL.md 流程与约束(约数千 token) | 任务命中该领域时加载 |
| L2 附件 | 参考文档、脚本、模板 | 真正需要细节时再读 |
与「专用工具堆成百上千」相比,Skills + 少量通用执行器(bash / 读写文件 / 代码解释器)把「选哪个 API」部分转成「检索哪份知识」——后者更贴近模型擅长的文本检索。一项能力做成 Skill 还是硬编码工具,可看三维:参数是否复杂嵌套、变更是否频繁、模型是否够强(弱模型更依赖严格 schema)。
安装来源不明的 Skill 前,按审查代码的标准审内容。
Agent 状态栏:把隐式状态写到末尾
模型不会自动归纳「我已经调了几次工具、当前 git 分支是什么、预算还剩多少」。Agent 状态栏(Agent Status Bar) 在每次推理前,把可变元信息追加到上下文末尾(末尾注意力权重通常更高,也避开改前缀):
[agent_status]
cwd: /Users/me/proj
git_branch: feature/refund
tool_calls_this_turn: 3
open_todos: 2
last_error: None
time_utc: 2026-07-24T08:00:00Z
Coding Agent 里常见字段:工作目录、分支、未暂存变更概览、最近提交。注意:
- 状态栏每次可变,所以不能塞进静态
system前缀。 - 内容应来自可信运行时,不要把外部网页原文直接贴进状态栏。
- 工具错误信息尽量结构化回写(行号、错误码),纠正效率远高于「失败了」。
压缩 vs 隔离:两种抗膨胀手段
轨迹只增不改时,窗口迟早触顶。两条主路径:
压缩(Compression)——在会话内把旧轨迹收成摘要或结构化笔记,再替换中间历史。要点:
- 触发条件要可测:token 占比、轮数、费用预算,而不是感觉「有点长了」。
- 摘要必须保留:未完成目标、关键约束、已确认事实、未决工具结果;丢掉「已关闭的枝节闲聊」可以,丢掉「订单号 / 用户否决过的方案」会直接导致错误重试。
- 压缩与 KV Cache 的张力:压缩等于改写历史前缀段,缓存从改写点失效——接受一次性代价,换后续更短的有效前缀。
- 产品模式可对缺失的 tool 配对做修补;若轨迹要进训练数据,则应拒绝静默伪造,避免污染。
隔离(Isolation)——把子任务丢给 子 Agent(Sub-Agent),只把任务说明 + 必要上下文 + 最终结果带回主轨迹,中间搜索噪声不进主窗口。适合:并行调研、可独立验证的子问题、需要不同工具集 / 模型的专项。
| 压缩 | 隔离(子 Agent) | |
|---|---|---|
| 改什么 | 主会话历史形态 | 任务边界与上下文范围 |
| 优点 | 单会话连续、状态统一 | 噪声不回流、可并行 |
| 成本 | 摘要错误会污染后续 | 上下文传递策略 + 额外调用 |
| 何时优先 | 同一任务长程推进 | 子问题边界清晰、可独立验收 |
二者常组合:主 Agent 压缩自己的轨迹,同时把「读完整本手册」类工作隔离出去。
本篇收束
- Agent 的「记忆」在工程上首先是 messages 列表的管理;ReAct 是列表的时间展开。
- 静态前缀稳定是 KV / Prompt Cache 的前提;动态信息只追加末尾。
- 提示词用 流程 + 可执行业务规则,少用无序规则堆;工具描述写清边界与示例。
- Skills 用渐进式披露扩展领域能力;状态栏补环境与进度元信息。
- 窗口不够时,压缩保连续、隔离保干净;注入防御从「指令/数据分家」做起。
下一篇进入第 3 章:记忆与知识库——跨会话的用户记忆怎么分层存储,RAG / Agentic RAG 如何给 Agent 补外部知识。
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:02. 上下文工程:Agent 只能看见你喂给它的东西
本文链接:https://www.sshipanoo.com/blog/ai/ai-agent-indepth/02-上下文工程/
本文最后一次更新为 天前,文章中的某些内容可能已过时!