上一篇把 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 的生长

带工具时,一轮最小循环是:

  1. system + user + tools
  2. 模型返回 assistant(可能含 tool_calls
  3. 框架执行工具,追加 tool 结果
  4. 再请求;若无 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 对齐):

  1. 系统提示词
  2. 工具定义
  3. 用户消息
  4. 模型回复(含思考 / 文本 / 工具请求)
  5. 工具结果

前两项构成相对稳定的静态前缀;后三项构成不断增长的轨迹(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 从亚秒级涨到数秒、月账单近乎翻倍——根因不在模型,在一行「看起来无害」的动态注入。

三条可执行原则:

  1. 系统提示词与工具定义定稿后不要热改(连空格、工具顺序都算)。
  2. 动态信息追加到末尾——时间、cwd、任务进度做成新消息或状态栏,不回写 system 前缀。
  3. 走标准消息 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 压缩自己的轨迹,同时把「读完整本手册」类工作隔离出去。

本篇收束

  1. Agent 的「记忆」在工程上首先是 messages 列表的管理;ReAct 是列表的时间展开。
  2. 静态前缀稳定是 KV / Prompt Cache 的前提;动态信息只追加末尾。
  3. 提示词用 流程 + 可执行业务规则,少用无序规则堆;工具描述写清边界与示例。
  4. Skills 用渐进式披露扩展领域能力;状态栏补环境与进度元信息。
  5. 窗口不够时,压缩保连续、隔离保干净;注入防御从「指令/数据分家」做起。

下一篇进入第 3 章:记忆与知识库——跨会话的用户记忆怎么分层存储,RAG / Agentic RAG 如何给 Agent 补外部知识。

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:02. 上下文工程:Agent 只能看见你喂给它的东西

本文链接:https://www.sshipanoo.com/blog/ai/ai-agent-indepth/02-上下文工程/

本文最后一次更新为 天前,文章中的某些内容可能已过时!