1. 为什么你的 Agent 跑到第 12 轮就崩了:Prompt 调优的收益递减与上下文膨胀
如果你最近在折腾 AI Agent,大概率经历过这个场景:Demo 阶段丝滑得不行,一旦把工具数量加到 5 个以上、对话轮次超过 10 轮,模型就开始胡言乱语——要么重复调用同一个工具,要么凭空捏造一个根本不存在的函数名,要么把三轮之前的用户需求忘得一干二净。你回头去改 Prompt,加一句“请务必仔细检查参数”,再跑一遍,好像好了两轮,第三轮又崩了。这就是 Prompt Engineering 的收益递减曲线:单点技巧能解决单点问题,但解决不了系统性问题。
我自己的体感是,Agent 的稳定性瓶颈几乎从来不在“模型够不够聪明”,而在“你每一轮到底喂了什么给它”。一个典型的 Agent 循环是这样的:用户输入 → 拼装上下文 → 模型决策 → 工具执行 → 结果写回上下文 → 再拼装 → 再决策。注意第二步和第五步,上下文是在不断增长的。工具返回的 JSON 可能几百行,RAG 召回的文档片段可能上千字,历史对话一轮不落全塞进去。跑到第 10 轮,输入 token 可能已经膨胀到 3 万以上,而输出始终只有几十个 token 的工具调用指令。输入输出比轻松达到 100:1。
这种结构带来两个致命问题。第一,KV Cache 命中率被破坏。只要你在系统提示开头放了一个动态时间戳,或者每轮重新序列化工具定义导致键顺序变化,前缀缓存全部失效,成本直接翻十倍。第二,模型在超长上下文中会出现“Lost in the middle”现象——它对开头和结尾的信息敏感,中间大段工具返回结果基本被忽略。于是你看到的现象就是:模型忘了最初的目标,开始瞎调工具。
所以这一篇不聊怎么写出“咒语级 Prompt”,而是聊怎么把上下文当成一个工程对象来管理。我会用 TaoToken 作为统一的 LLM 接入通道,给你一套可复制的 Agent 上下文配置模板,以及三步验证动作:构造多轮工具调用、观察上下文膨胀、对比裁剪前后的成功率。整套流程你可以直接跑通,不需要自己维护多套 API Key,也不需要为了切换模型改代码。
2. TaoToken 统一 Key 接入:把模型通道和上下文工程解耦
在讲上下文配置之前,先花一点篇幅把接入层说清楚。原因很简单:Context Engineering 的一个核心实践是“保持前缀稳定”,而如果你每换一个模型就要改 Base URL、改 Key、改请求格式,前缀稳定性根本无从谈起。TaoToken 在这里的角色是一个统一的 API 通道,你用同一个 Key、同一个 Base URL,就能调用不同厂商的模型。这样你的 Agent 代码里,模型切换只是改一个 Model ID 字符串,上下文组装逻辑完全不动。
先拿 Key。打开https://taotoken.net/api-keys,登录后创建一个 API Key,复制出来。注意这个 Key 只在创建时完整显示一次,先存到环境变量里,别硬编码进代码。
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"TaoToken 的 API 端点兼容 OpenAI 风格的请求格式,所以你可以直接用 openai 的 SDK,只需要把 base_url 指过来。这一点对上下文工程很关键:你的消息数组结构、工具定义结构、缓存断点标记方式,都保持标准格式,不会被某个厂商的私有协议绑架。
from openai import OpenAI import os client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], ) resp = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=[{"role": "user", "content": "用一句话说明什么是上下文工程"}], ) print(resp.choices[0].message.content)这段代码跑通,说明你的通道没问题。接下来所有上下文组装的实验,都基于这个 client。如果你更习惯用 Claude Code 这类工具做长任务编码,TaoToken 也提供了对应的接入方式,Base URL 同样是https://taotoken.net/api,Key 用同一个,Model ID 按你需要的填。三件套就是:Base URL、API Key、Model ID,缺一不可。
这里要强调一个容易踩的坑:不要在系统提示里放动态内容。我见过太多人为了“让模型知道现在几点”,在 system message 开头写当前时间:2025-xx-xx xx:xx:xx。这一行会让后面所有 token 的缓存全部失效。正确做法是把时间信息放到用户消息里,或者放到上下文末尾,保持前缀稳定。
3. 可复制的 Agent 上下文配置模板:JSON 结构 + 裁剪策略
现在进入核心部分。我给你一个可以直接用的上下文配置模板,用 JSON 描述,包含四个区域:稳定前缀区、工具定义区、动态观察区、目标复述区。这个结构的设计目标只有一个:让 KV Cache 尽可能命中,同时让模型在每一轮都能看到当前最重要的信息。
{ "context_config": { "stable_prefix": { "system_instruction": "你是一个任务执行 Agent。你的目标是完成用户交付的任务。你可以调用工具,但每次只调用一个。如果工具返回错误,保留错误信息并尝试修正。", "tool_definitions": [ { "name": "search_docs", "description": "在知识库中检索相关文档片段", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "检索关键词"} }, "required": ["query"] } }, { "name": "read_file", "description": "读取指定路径的文件内容", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"} }, "required": ["path"] } } ] }, "dynamic_observation": { "max_tool_result_chars": 2000, "truncate_strategy": "head_tail", "keep_error_messages": true, "rag_top_k": 3, "rag_max_chars_per_doc": 800 }, "goal_restate": { "enabled": true, "position": "end_of_context", "template": "当前任务目标:{goal}。已完成步骤:{completed_steps}。下一步请继续。" } } }这个模板里有几个关键决策,我逐个解释。
stable_prefix里的 system_instruction 和 tool_definitions 是永远不变的部分。工具定义放在前缀区,不要每轮动态增删。Manus 的实践表明,动态增删工具会导致两个问题:一是工具定义通常位于上下文最前端,任何改动都会让后续所有 KV Cache 失效;二是当历史消息里引用了已经被移除的工具时,模型会幻觉出无效调用。所以工具集保持全量且稳定,需要限制行动空间时,用响应预填充或者 logits 掩码,而不是改上下文。
dynamic_observation里的max_tool_result_chars是裁剪阈值。工具返回结果超过 2000 字符时,采用 head_tail 策略:保留前 800 字符和后 800 字符,中间用...[已截断 N 字符]...标记。为什么保留尾部?因为很多工具的报错信息在末尾,堆栈跟踪的最后几行往往最关键。keep_error_messages设为 true,意味着错误信息不裁剪,完整保留。这一点反直觉但极其重要:把失败的行动和错误观察留在上下文里,模型会隐式更新信念,降低重复犯错的概率。如果你把错误抹掉重试,模型学不到任何东西。
rag_top_k和rag_max_chars_per_doc控制 RAG 注入量。召回 3 篇,每篇最多 800 字符。超过这个量,模型注意力会被稀释。如果你需要更多文档,宁可分多轮检索,也不要一次性塞进去。
goal_restate是解决“Lost in the middle”的利器。每一轮在上下文末尾追加一条目标复述,把全局计划推入模型的近期注意力范围。Manus 用 todo.md 做这件事,我们这里用一条结构化消息实现同样效果。
组装上下文的 Python 代码大概长这样:
def build_context(goal, history, tool_results, completed_steps): messages = [] messages.append({"role": "system", "content": STABLE_SYSTEM_INSTRUCTION}) for step in history: messages.append(step) for tr in tool_results: content = truncate_head_tail(tr["content"], 2000) messages.append({"role": "tool", "content": content, "tool_call_id": tr["id"]}) restate = f"当前任务目标:{goal}。已完成步骤:{completed_steps}。下一步请继续。" messages.append({"role": "user", "content": restate}) return messages注意 history 里的消息只追加不修改。不要回头去编辑之前轮次的内容,那会破坏前缀稳定性。序列化 JSON 时用json.dumps(obj, sort_keys=True)保证键顺序确定,否则缓存命中率会悄无声息地掉下去。
4. 三步验证:多轮工具调用、上下文膨胀观察、裁剪前后成功率对比
配置写好了,怎么验证它真的有效?我给你三个可执行的动作,按顺序做一遍,你就能看到上下文工程的实际收益。
第一步,构造一个多轮工具调用任务。写一个循环,让 Agent 连续执行 15 轮工具调用,每轮记录输入 token 数和输出 token 数。任务可以很简单:让 Agent 反复检索文档并读取文件,直到找到某个特定信息。代码框架如下:
import tiktoken def run_agent_loop(goal, max_turns=15): history = [] tool_results = [] completed = [] stats = [] for turn in range(max_turns): messages = build_context(goal, history, tool_results, completed) input_tokens = count_tokens(messages) resp = client.chat.completions.create( model="claude-sonnet-4-20250514", messages=messages, tools=TOOL_DEFINITIONS, ) output_tokens = resp.usage.completion_tokens stats.append({"turn": turn, "input": input_tokens, "output": output_tokens}) # 执行工具调用,追加结果... return stats跑完之后打印 stats,你会看到输入 token 从第一轮的 2000 左右,一路涨到第 15 轮的 25000 以上,而输出始终在 50 到 150 之间。这个 100:1 的倾斜比例就是上下文膨胀的直接证据。
第二步,观察上下文膨胀对延迟和成本的影响。把每一轮的 TTFT(首 token 时间)和总延迟记下来。你会发现,在没有缓存命中的情况下,第 15 轮的延迟可能是第 1 轮的 8 到 10 倍。如果你在系统提示里加了动态时间戳,这个恶化会更明显。反过来,如果你保持了前缀稳定,并且工具定义不变,那么从第 2 轮开始,前缀部分的 KV Cache 应该持续命中,延迟增长会平缓很多。
第三步,对比裁剪前后的成功率。准备 20 个测试任务,每个任务需要 10 轮以上工具调用。先用不裁剪的版本跑一遍,记录成功完成的任务数。再用上面模板里的裁剪策略跑一遍,同样记录。我实测下来,在工具返回结果较大的场景里,裁剪版本的成功率能从 60% 左右提升到 85% 以上。失败案例的典型表现是:不裁剪版本在第 12 轮左右开始重复调用同一个工具,或者调用一个不存在的工具名;裁剪版本因为上下文更干净,目标复述又把注意力拉回来,所以能继续推进。
这三个动作做完,你对“上下文工程决定 Agent 稳定性”这句话会有体感,而不只是概念上的认同。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
接入和运行过程中,有几个报错几乎每个人都会遇到。我按出现频率排一下,给你对照排查。
401 Unauthorized最常见。原因通常是 API Key 没设对,或者环境变量没生效。检查echo $TAOTOKEN_API_KEY是否有值,检查代码里读的是不是这个变量名。还有一种情况是 Key 复制时带了空格,或者把创建时显示的完整 Key 截断了。重新去https://taotoken.net/api-keys生成一个,完整复制。
local proxy failed或类似的连接错误。先确认 Base URL 写的是https://taotoken.net/api,不要多加路径,也不要少写。然后确认你的网络环境能正常访问这个域名。如果你在代码里同时设了 HTTP_PROXY 之类的环境变量,先 unset 掉再试。
reading choices报错,通常表现为KeyError: 'choices'或者NoneType object is not subscriptable。这说明请求返回的结构里没有 choices 字段,大概率是请求本身失败了,返回的是一个错误对象。打印完整的resp看看,通常是模型 ID 写错了,或者请求体格式不对。检查 model 字段是不是你账号下有权限的模型。
OAuth相关报错,一般出现在用 Claude Code 或类似工具接入时。如果你用的是 API Key 方式,不应该走 OAuth 流程。检查配置文件里是不是同时存在 OAuth token 和 API Key,导致冲突。以 Claude Code 为例,配置文件里 Base URL、API Key、Model ID 三件套要写全,缺一个都可能触发回退到 OAuth 逻辑然后失败。
还有一个隐蔽的坑:工具调用返回的tool_call_id对不上。如果你在裁剪上下文时把某条 tool 消息删了,但 assistant 消息里的 tool_call_id 还留着,下一轮请求会报错。解决办法是裁剪时成对处理,要么都留,要么都删,并且用 goal_restate 消息来补偿信息损失。
6. 从 Demo 到生产:把上下文工程变成日常习惯
聊到这里,你应该已经有一套可跑的配置和验证方法了。最后说几个我踩过坑之后养成的习惯,你可以直接拿去用。
第一,每次改 Agent 逻辑,先跑一遍 15 轮循环,看输入 token 曲线。如果曲线斜率突然变陡,说明某处注入了不该注入的大块内容。第二,工具返回结果永远先过裁剪函数再进上下文,不要相信任何外部工具会返回“刚好合适”的长度。第三,错误信息不要删,保留在上下文里,但可以裁剪掉无关的堆栈中间部分,保留错误类型和最后几行。第四,目标复述每轮都做,成本很低,收益很高。第五,如果你需要切换模型做对比测试,用 TaoToken 的同一个 Key 改 Model ID 就行,上下文组装代码一行不用动,这样你对比的才是模型能力差异,而不是接入方式差异。
Context Engineering 不是什么新概念,它只是把“给模型喂什么”这件事从随手拼字符串变成了有结构的工程。你不需要一次做到完美,先把稳定前缀和裁剪策略落地,跑一遍三步验证,看到成功率变化,剩下的优化方向自然就清楚了。