1. Agent 长对话为什么一定会撑爆上下文窗口
先说结论:Agent 的 messages 数组是只增不减的,只要任务够复杂,任何上下文窗口都会被填满。这不是模型能力问题,是架构决定的。
我拿一个真实任务举例:让 Agent 去分析 nginx 日志,提取 error,统计近一天内每半小时的 error 数量、每个接口的请求量,按数量降序排序,最后写入 statistic.txt。这个任务听起来不复杂,但 Agent 实际执行时会经历这些步骤:先 ls 找日志文件,再 grep 提取 error 行,然后 awk 按时间窗口聚合,再 sort 排序,中间可能还要 cat 看文件格式、head 确认字段、wc 统计行数。每一轮工具调用,messages 至少新增两条消息(assistant 的 tool_call 和 tool 的返回结果),而 grep 和 cat 的输出动辄几十上百行。
算一笔账:假设每轮工具输出平均 800 tokens,10 轮就是 8000 tokens,加上 system prompt、历史对话、工具定义,轻松突破 15000 tokens。如果用的是本地部署的小模型,context window 可能只有 4096 或 8192,跑三四轮就满了。就算用云端大模型,窗口有 128K,一个复杂任务跑 50 轮循环,照样能塞满。
有人会说:现在模型窗口越来越大了,还需要压缩吗?我的实测感受是,窗口变大只是把爆炸时间往后推,没有解决根本问题。三个原因:第一,token 费用是按输入量算的,历史越长每轮请求越贵;第二,超长上下文会让模型注意力分散,容易在无关信息里迷失重点;第三,响应速度会明显变慢,因为模型要处理的输入变多了。
所以上下文压缩不是可选项,是 Agent 工程化的必修课。这篇就聚焦摘要法这一条主线,把配置、阈值、提示词模板、验证动作全部拆开讲清楚,同时演示怎么用 TaoToken 的统一 Key 把 LLM 通道接起来,让压缩逻辑跑通。
2. 用 TaoToken 统一 Key 接入 LLM 通道
在写压缩代码之前,得先把 LLM 调用通道搭好。Agent 的压缩逻辑需要频繁调用 LLM 来生成摘要,如果每次都要切换不同的 API Key、不同的 base_url,维护成本会很高。TaoToken 的思路是提供一个统一的 API 入口,你只需要一个 Key,就能在多个模型之间切换,压缩用的摘要模型和主任务用的模型可以分开配置。
TaoToken 是什么:它是一个 LLM API 聚合通道,提供统一的 OpenAI 兼容接口。你可以把它理解成一个"API 路由器",底层对接了多种模型,上层暴露标准的 /v1/chat/completions 接口。对 Agent 开发者来说,好处是不用为每个模型单独写适配代码,换模型只改一个 model 字段。
适合谁用:正在做 Agent 长对话治理、需要频繁调用 LLM 做摘要压缩、或者想在多个模型之间做成本对比的开发者。如果你只是偶尔调一次 API,可能感受不到统一 Key 的价值;但如果你在跑 Agent 循环,每轮都要调 LLM,统一通道能省很多事。
接入前你需要准备的东西:一个 TaoToken 账号,一个 API Key。Key 在控制台的 API Keys 页面生成,地址是 https://taotoken.net/console/api-keys 。生成后保存好,后面配置里要用。
这里要区分两个地址:官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api ,注意 API 地址后面不加 UTM 参数,直接用于代码里的 base_url。
如果你还没决定用哪个模型做摘要,可以先去模型对话页面试一下不同模型的摘要效果,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。摘要任务对模型要求不高,小模型也能胜任,关键是提示词要写清楚。
3. 可复制的 config.toml 与 settings.json 配置骨架
配置分两块:一块是 TaoToken 的接入配置,一块是压缩策略的参数配置。我习惯把接入信息放在 config.toml,把压缩阈值和提示词放在 settings.json,这样调参的时候不用动代码。
先看 config.toml:
# config.toml - TaoToken 接入配置 [llm] base_url = "https://taotoken.net/api" api_key = "sk-your-taotoken-key-here" main_model = "gpt-4o-mini" # 主任务模型 summary_model = "gpt-4o-mini" # 摘要专用模型,可以用更便宜的 timeout = 60 max_retries = 3 [agent] max_iterations = 30 # Agent 最大循环次数 compact_enabled = true # 是否开启压缩再看 settings.json,这里放压缩策略的核心参数:
{ "compact": { "threshold": 20, "keep_recent": 6, "summary_prompt": "Summarize the following conversation history. Keep all important facts, file paths, command results, and decisions. Be concise but don't lose critical details.", "summary_prefix": "[Previous conversation summary]: ", "ack_message": "Understood. I have the context from our previous conversation. Let me continue." } }参数解释一下。threshold 是触发压缩的消息条数阈值,超过 20 条就压缩。keep_recent 是保留最近多少条不压缩,设为 6 意味着最近 3 轮对话(每轮 user+assistant 两条)保持原样。summary_prompt 是发给 LLM 的摘要指令,要求保留事实、文件路径、命令结果和决策。summary_prefix 是摘要插入 messages 时的前缀,让模型知道这是历史摘要。ack_message 是压缩后插入的 assistant 确认消息,保持对话角色交替的完整性。
为什么 threshold 设 20、keep_recent 设 6?这是我试过几组参数后的经验值。threshold 太小会导致压缩过于频繁,每次压缩都要调一次 LLM,反而增加开销;太大则压缩效果不明显。20 条大约对应 10 轮工具调用,是一个比较自然的压缩点。keep_recent 设 6 是因为最近几轮对话往往包含当前任务的上下文,压缩掉会导致模型"失忆",保留 6 条能覆盖最近 3 轮的关键信息。
如果你用的是本地小模型,context window 只有几千 tokens,建议把 threshold 降到 10,keep_recent 降到 4。如果是云端大模型,可以适当放宽到 30 和 8。
4. 摘要法压缩的核心实现与提示词模板
配置准备好之后,核心逻辑就是一个 compact_messages 函数。它的职责很明确:检查消息条数,没超阈值就原样返回,超了就压缩旧消息、保留最近消息、重新组装。
import json import toml from openai import OpenAI # 加载配置 config = toml.load("config.toml") settings = json.load(open("settings.json")) client = OpenAI( base_url=config["llm"]["base_url"], api_key=config["llm"]["api_key"] ) COMPACT_THRESHOLD = settings["compact"]["threshold"] KEEP_RECENT = settings["compact"]["keep_recent"] SUMMARY_PROMPT = settings["compact"]["summary_prompt"] SUMMARY_PREFIX = settings["compact"]["summary_prefix"] ACK_MESSAGE = settings["compact"]["ack_message"] SUMMARY_MODEL = config["llm"]["summary_model"] def compact_messages(messages): if len(messages) <= COMPACT_THRESHOLD: return messages system_msg = messages[0] old_messages = messages[1:-KEEP_RECENT] recent_messages = messages[-KEEP_RECENT:] old_text = "" for msg in old_messages: role = msg.get("role", "unknown") content = msg.get("content", "") if content: old_text += f"[{role}]: {content}\n" summary_response = client.chat.completions.create( model=SUMMARY_MODEL, messages=[ {"role": "system", "content": SUMMARY_PROMPT}, {"role": "user", "content": old_text} ] ) summary = summary_response.choices[0].message.content return [ system_msg, {"role": "user", "content": f"{SUMMARY_PREFIX}{summary}"}, {"role": "assistant", "content": ACK_MESSAGE}, *recent_messages ]这段代码有几个关键点。第一,system_msg 永远保留,因为它是 Agent 的核心指令,压缩进摘要会丢失基础设定。第二,old_messages 是要被压缩的部分,recent_messages 是保留原样的部分,切分位置在 messages[1:-KEEP_RECENT]。第三,摘要调用用的是 summary_model,可以和主任务模型不同,摘要任务对模型能力要求不高,用便宜的小模型就行。
摘要提示词模板是压缩效果的关键。我用的模板是:
Summarize the following conversation history. Keep all important facts, file paths, command results, and decisions. Be concise but don't lose critical details.
这个模板强调了三件事:保留事实(facts)、保留文件路径(file paths)、保留命令结果(command results)。为什么强调这些?因为 Agent 后续决策依赖这些具体信息。比如前面 grep 出来的日志路径、awk 统计出的数字、用户指定的输出文件名,这些如果丢了,Agent 就得重新执行工具去获取,压缩就白做了。
如果你做的是结构化任务,比如订票、查数据,可以把提示词改成要求输出 JSON 格式:
{ "summary_prompt": "Summarize the conversation into a JSON object with keys: entities, state, intent. Keep all concrete values." }这样摘要结果本身就是结构化的,后续解析更方便。
压缩在 Agent 循环里的调用位置也很重要。我的做法是在每轮循环开始前检查一次:
def run_agent(user_message, max_iterations=30): messages = [ {"role": "system", "content": "You are a helpful agent..."}, {"role": "user", "content": user_message} ] for i in range(max_iterations): messages = compact_messages(messages) # 每轮开始前检查 response = client.chat.completions.create( model=config["llm"]["main_model"], messages=messages, tools=tools ) # ... 处理工具调用,追加消息到 messages这样 messages 的数量会像锯齿波一样:涨到阈值 → 压缩回去 → 继续涨 → 再压缩。永远不会超过阈值太多,Agent 可以持续工作下去。
5. 验证压缩效果:token 占用对比与请求测试
代码写完了,怎么验证压缩真的生效了?我一般做两个动作:一是打印压缩前后的消息条数和 token 估算,二是实际发一次请求看返回是否正常。
先加一段验证代码:
def estimate_tokens(text): # 粗略估算:英文约 4 字符 1 token,中文约 1.5 字符 1 token return len(text) // 3 def print_compact_stats(messages_before, messages_after): before_count = len(messages_before) after_count = len(messages_after) before_tokens = sum(estimate_tokens(str(m.get("content", ""))) for m in messages_before) after_tokens = sum(estimate_tokens(str(m.get("content", ""))) for m in messages_after) print(f"压缩前: {before_count} 条消息, 约 {before_tokens} tokens") print(f"压缩后: {after_count} 条消息, 约 {after_tokens} tokens") print(f"压缩率: {(1 - after_tokens / before_tokens) * 100:.1f}%")跑一个模拟场景:构造 25 条消息,其中包含几段长文本(模拟工具输出),然后调用 compact_messages,打印统计。预期结果是消息条数从 25 降到 9(1 条 system + 1 条摘要 + 1 条确认 + 6 条最近消息),token 占用减少 60% 到 80%。
实测下来,一个包含 10 轮工具调用的对话,压缩前约 12000 tokens,压缩后约 3000 tokens,压缩率 75% 左右。摘要本身消耗约 500 tokens 的输入和 200 tokens 的输出,但这是一次性开销,后续每轮请求都省下了 9000 tokens 的输入。
第二个验证动作是发一次真实请求,确认压缩后的 messages 能被模型正常理解:
def test_compressed_context(): messages = build_long_conversation() # 构造长对话 compressed = compact_messages(messages) response = client.chat.completions.create( model=config["llm"]["main_model"], messages=compressed ) print(response.choices[0].message.content)如果模型能基于摘要继续回答,说明压缩没有丢失关键上下文。如果模型回答"我不知道之前发生了什么",说明摘要提示词需要调整,要更强调保留事实和决策。
这里有个细节:压缩后插入的 assistant 确认消息("Understood. I have the context...")很重要。它保持了对话角色的交替,让模型知道摘要已经被"接受"了。如果省略这条,messages 会变成 user(摘要)后面直接跟 user(最近消息),角色不交替,有些模型会报错或行为异常。
6. 压缩场景下的常见报错与排查
压缩逻辑跑起来之后,容易遇到几类问题,我按出现频率排一下。
第一类:摘要调用超时或返回空。原因是 old_text 太长,摘要模型处理不过来。排查方法是打印 old_text 的长度,如果超过 10000 字符,考虑分批摘要或者先截断。解决方式是在 config.toml 里把 summary_model 换成上下文窗口更大的模型,或者把 threshold 调小,让每次压缩的旧消息少一些。
第二类:压缩后模型"失忆",重复执行已完成的步骤。原因是摘要丢失了关键状态信息。排查方法是把摘要内容打印出来,看是否包含任务进度、已完成步骤、待办事项。解决方式是修改 summary_prompt,明确要求保留 task progress 和 completed steps。我常用的增强版提示词是:
Summarize the conversation. Keep: 1) all file paths and command results, 2) task progress and completed steps, 3) pending actions, 4) user preferences and constraints. Be concise.
第三类:messages 角色顺序错乱导致 API 报错。原因是压缩后组装的消息列表角色不交替。排查方法是打印压缩后每条消息的 role,确认是 system → user → assistant → user → assistant 的交替顺序。解决方式是确保摘要消息用 user 角色,确认消息用 assistant 角色,最近消息保持原有角色。
第四类:压缩频率过高导致 token 费用不降反升。原因是 threshold 设得太小,每几轮就压缩一次,摘要调用的开销超过了节省的输入 token。排查方法是统计压缩次数和摘要调用总 token。解决方式是把 threshold 调大,比如从 10 调到 20 或 30,让每次压缩处理更多旧消息,摊薄摘要成本。
第五类:工具调用消息在压缩后丢失 tool_call_id,导致后续请求报错。原因是摘要只保留了 content,没有保留 tool_calls 结构。排查方法是检查 old_messages 里是否有带 tool_calls 字段的消息。解决方式是在压缩时跳过带 tool_calls 的消息,或者把它们的内容提取出来放进摘要文本,但不要保留原始结构。更稳妥的做法是确保 keep_recent 覆盖到最近一次工具调用,这样 tool_call_id 不会进入压缩区。
如果你在接入 TaoToken 的过程中遇到 Key 无效或 401 报错,先去 API Keys 页面确认 Key 是否正确生成、是否过期,地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。如果确认 Key 没问题但还是报错,检查 base_url 是否写成了 https://taotoken.net/api ,注意末尾不要加斜杠,也不要在 API 地址上加 UTM 参数。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有完整的接口说明和错误码对照。如果你打算长期跑 Agent 任务,压缩逻辑会频繁调用 LLM,可以考虑 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,适合需要稳定通道和批量调用的场景。
最后说一个我踩过的坑:压缩后的摘要消息不要用 system 角色。我一开始图省事,把摘要塞进 system 消息里,结果模型把摘要当成了系统指令,行为变得很奇怪。正确做法是用 user 角色,让模型把它当作"用户提供的上下文信息"来处理。这个细节在接入文档里没有明说,但实测下来影响很大。