上下文预算不是省 token 的小技巧,而是长任务 Agent 的稳定性设计。上下文管不住,任务跑不远;账管不住,任务跑不起。
一、两个坑:token 烧完,目标跑丢
我踩过两类事故,后来发现这几乎是长任务 Agent 的标准死法。
第一类是上下文溢出(context overflow)。一个数据迁移任务跑到几十轮,工具输出一路堆进上下文:日志、报错、表结构、测试结果。到某一轮,请求直接撞上上下文窗口上限,API 开始报错;更隐蔽的是另一种结局——harness 静默截断了旧消息,模型看不见自己早前的决策,开始重做已经完成的工作,token 成倍地烧。
第二类是目标丢失(goal drift)。上下文被几百条工具结果塞满之后,开头的任务目标被稀释成一粒尘埃。模型对最近内容的注意力最高,于是围着几十轮之后的某个旧报错打转,把一段无关代码反复重构,目标早被挤出了注意力中心。账单照跑,目标没了。
这两类失效还会互相放大:溢出后粗暴截断,截断丢掉目标和约束,跑偏加速;跑偏后返工变多,工具结果堆积更快,溢出来得更早。我后来把治理手段收敛成 harness 层的四招,全部不指望模型自觉。顺带交代背景:我的长任务 Agent 都是通过 4sapi(https://4sapi.com)的 OpenAI 兼容接口接入模型的,后文的代码和计费口径都按这个接法展开。
二、上下文预算:把上下文当内存管
思路借自系统编程:上下文窗口就是一块大小固定的内存,装什么、留什么、何时回收,都要有明确规则。没有预算的上下文管理,等于写 C 不做内存规划——短任务看不出来,长任务必炸。
预算的含义是给每类内容定额:系统提示占多少、工具结果占多少、对话历史占多少、复述与记忆注入占多少。每往上下文里追加一条内容,先记账,再看额度;额度吃紧就触发预设动作——卸载或压缩——而不是等 API 报错再手忙脚乱。
这么做有两个收益。一是失效变得可预测:溢出从随机事故变成到达阈值后的既定流程。二是成本变得可预测:长任务的输入 token 是逐轮累积的,第 N 轮的请求背着前 N-1 轮的全部历史重付一遍输入费用,不定额的任务,账单曲线是超线性的。
三、原理速览:四招卡在生命周期的哪三段
四招对应两类失效:预算与卸载、压缩管装得下,解决溢出;todo-state 复述、跨会话记忆管记得住,解决跑偏。四招在任务生命周期里的位置如下:
任务启动
|
v
[执行前] 定预算:系统提示 / 工具结果 / 历史 / 复述,分类定额
|
v
[执行中] 逐轮监控占用
|-- 工具结果过大 ---------> 卸载:正文移出上下文,只留指针
|-- 占用逼近总预算 -------> 压缩:旧历史折叠成摘要
|-- 每隔 N 步 / 到里程碑 --> 复述:注入 todo-state,重新锚定目标
|
v
[执行后] 会话收尾:结论、决策、未竟事项写入跨会话记忆
|
v
下个会话冷启动:预算重置,记忆按需装载,锚点不裸奔
前两招发生在每一轮执行中,是高频小动作;复述是中频节拍器;记忆是低频大动作,发生在会话边界。四招配合,上下文从只进不出的垃圾桶,变成有进出、有回收、有交接的内存池。
四、第一招:预算与卸载——大东西不进上下文
卸载解决的问题是:体量最大的内容根本不该完整地进上下文。典型对象是工具结果——长日志、完整文件、网页正文、测试输出、大型 JSON。其共性是信息密度低、可再获取、体量动辄数千 token。
做法是在工具层包一道闸门:返回结果超过阈值,就写入工作区文件或外部存储,上下文里只放三样东西——摘要、指针(路径、行号区间、URL、ID)、再读取的方法。模型需要细节时,用带 offset 的读取工具按需取一小段。
误用方式主要有两种。一是卸载太狠,模型拿不到必要细节就开始盲猜,猜错再重读,反而更贵——卸载的前提是指针可解析,模型确实拿得到内容。二是只盯工具结果、不管系统提示:工具定义和系统提示是每个会话都要付一次的固定开销,定额定在那里,瘦身才有抓手。
五、第二招:压缩(compaction)——旧历史折叠成摘要
卸载管增量,压缩管存量。历史越滚越厚,等占用达到总预算的某个比例就触发压缩——我的经验是八成左右动手,别等爆。压缩把较旧的历史折叠成要点摘要:保留结论、决策、未完成事项和关键数字,丢弃过程性细节,最近几轮原样保留。
压缩有两种粒度。整段压缩实现简单,但一次丢得多;分段压缩每完成一个里程碑就压一段,更平滑,代价是调用次数多。还有一条容易被忽略:压缩摘要由模型生成时会引入误差,重要锚点不该走这条通道——让 harness 从结构化状态(todo 列表、里程碑记录)直接生成,模型只负责压缩真正的对话过程。
误用方式:把目标和硬约束一起压进摘要。摘要是转述,转述会弱化措辞,严禁改动生产数据库被压成注意数据库安全,跑偏就从这里开始。另外压缩本身也是一次调用,有成本,触发阈值设得太低,压缩会比正经干活还频繁。
六、第三招:todo-state 复述——把目标钉回注意力中心
跑偏的根因是注意力稀释:上下文越长,开头的目标权重越低。todo-state 复述是对症药——每隔 N 步或在每个里程碑后,由 harness 把目标、硬约束、已完成里程碑和当前 todo 状态拼成一段结构化文本,作为新消息注入上下文末尾,借模型对最近内容的高注意力重新锚定。
关键在 state 由 harness 维护这半句。todo 清单是结构化数据,存在 harness 手里,完成了什么、还剩什么,都有确定性记录;复述内容照单生成,不让模型凭记忆自由发挥。模型自己复述自己的记忆,等于让失忆的人写备忘录。
误用方式有两种:复述写得太长,自身变成预算黑洞;间隔拍脑袋,太疏等于没做,太密浪费 token。我的做法是固定格式、固定间隔、按里程碑节奏校准,复述控制在几百 token 以内。
七、第四招:跨会话记忆——冷启动不裸奔
任务是跨天的,会话却会断。没有跨会话记忆的任务,每次冷启动都从零开始:重新读代码、重新踩坑、重新做已经做过的决策。
做法是在会话收尾时生成一份交接文档(handoff):目标、当前状态、关键决策及理由、已排除的路径、下一步与注意事项,写入外部存储,文件和数据库皆可。下个会话冷启动时按需装载:摘要进上下文,原文留指针。它和卸载的区别在时间尺度——卸载是任务内挪位置,记忆是跨会话持久化。
误用方式:把记忆当垃圾场,什么都标成重要,冷启动注入越滚越大,等于把溢出挪到了下一场任务的开头。记忆要有准入标准、容量上限和过期机制,每条记忆的可信度标注清楚,过期的该扔就扔。
八、什么该压,什么绝不能压
四招落到执行层,先回答分类问题。我的分法如下表:
| 内容类型 | 例子 | 处理策略 | 原因 |
|---|---|---|---|
| 目标与验收标准 | 任务目标、成功判据 | 原文保留,复述时逐字带入 | 跑偏的根因就是它丢失 |
| 硬约束 | 禁改生产库、提交行数上限 | 原文保留,逐字复述 | 摘要会弱化禁令措辞 |
| 已完成里程碑 | 已通过的测试、已完成的迁移 | 保留一行式状态 | 防止重复劳动 |
| 关键决策 | 方案取舍及理由 | 保留结论与理由的短摘要 | 重复决策又慢又易自相矛盾 |
| 已排除路径 | 试过并否定的方案 | 保留一行已排除记录 | 防止再走一遍死路 |
| 中间产物 | 工具原始输出、长日志、网页正文 | 卸载出上下文,留指针 | 体量大、密度低、可再取 |
| 过程细节 | 重试记录、临时报错 | 可压缩或丢弃 | 已被结论覆盖 |
一句话版本:锚点内容(目标、约束、里程碑)绝不进摘要,中间产物绝不占正文。压缩只压缩过程,不压缩方向。
九、长任务的 token 账:成本结构(估算口径)
长任务的账单有个特点:输入远大于输出,而且逐轮累积。同样的对话轮次,越到后段每轮越贵,因为历史在滚雪球。账要按结构看,下表是我对一个典型长任务会话的额度拆分(估算口径,按任务类型浮动):
| 上下文成分 | 额度占比(估算口径) | 说明 |
|---|---|---|
| 系统提示与工具定义 | 10% | 固定开销,每会话付一次,值得专门瘦身 |
| 工具结果 | 45% | 最大头,卸载的主要对象 |
| 对话历史 | 35% | 随轮数增长,压缩的主要对象 |
| 复述与记忆注入 | 10% | 防跑偏的保险费,不能省也不能膨胀 |
同样的预算下,管理强度不同,结果差异很大。下表按一个数十轮任务粗算(估算口径,相对值):
| 管理策略 | 相对 token 消耗(估算口径) | 失效风险 |
|---|---|---|
| 不做管理,历史自由增长 | 1.0x(基准) | 后段逼近上限,溢出概率随轮数上升 |
| 只做粗暴截断 | 约 0.7x–0.9x | 截断丢锚点,跑偏返工可能吃掉省下的部分 |
| 预算 + 卸载 + 压缩 + 复述 | 约 0.4x–0.6x | 溢出可控、跑偏率下降,代价是压缩调用与工程量 |
省 token 的优先级由此很清楚:先卸载工具结果,砍最大头;再压历史,砍增长项;最后才轮到抠系统提示。顺序反了,工程量就花在小头上面。
十、接入教程:用 Python 写一个上下文预算管理器
接入层的原则是代码只对着 OpenAI 兼容接口写,不绑任何单一家。我的长任务 Agent 一直通过 4sapi(https://4sapi.com)的中转线路接入多家模型,换模型只改环境变量,harness 代码一行不动;中转层顺带在多条线路间做负载均衡,限流和重试摊开处理,长任务跑到后段的请求成功率稳不少。
下面是一个最小可用的上下文预算管理器,覆盖三件事:分类定额、超限触发压缩、todo-state 复述注入:
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ["LLM_BASE_URL"],
)
def estimate_tokens(text: str) -> int:
"""粗估 token:CJK 字符按 1 字 1 token 计,其余按 4 字符 1 token 计。"""
cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
return cjk + (len(text) - cjk) // 4 + 1
class ContextBudget:
"""上下文预算管理器:分类定额、超限触发压缩、todo-state 复述注入。"""
# 压缩时绝不进摘要的锚点:目标、硬约束、已完成里程碑
KEEP_KEYS = ("goal", "constraints", "milestones")
def __init__(self, total_budget: int = 64_000, trigger: float = 0.8,
restate_every: int = 8):
self.total = total_budget
self.trigger = int(total_budget * trigger) # 占用达到八成即压缩
self.restate_every = restate_every # 每隔 N 步复述一次
# 每类内容的额度占比,合计应为 1
self.quotas = {
"system": 0.10,
"tool_result": 0.45,
"history": 0.35,
"restate": 0.10,
}
self.used = {k: 0 for k in self.quotas}
self.keep: dict[str, str] = {} # 锚点内容原文
self.todo_state: list[str] = [] # harness 维护的当前 todo 状态
self.step = 0
def register(self, key: str, text: str) -> None:
"""登记压缩时必须原样保留的锚点内容。"""
self.keep[key] = text
def update_todos(self, todo_state: list[str]) -> None:
"""同步最新 todo 状态,供复述注入使用。"""
self.todo_state = todo_state
def charge(self, kind: str, text: str) -> dict:
"""一条内容入上下文前先记账,返回该类额度的占用比例。"""
cost = estimate_tokens(text)
self.used[kind] = self.used.get(kind, 0) + cost
quota = max(int(self.quotas[kind] * self.total), 1)
return {"kind": kind, "cost": cost,
"quota_ratio": round(self.used[kind] / quota, 2)}
def total_used(self) -> int:
return sum(self.used.values())
def check(self) -> list[str]:
"""每个执行步调用一次,返回本步该执行的 harness 动作。"""
self.step += 1
actions: list[str] = []
if self.total_used() >= self.trigger:
actions.append("compact")
if self.step % self.restate_every == 0:
actions.append("restate")
return actions
def build_restatement(self) -> str:
"""拼装 todo-state 复述块:锚点加当前进度,几百 token 以内。"""
lines = ["[todo-state 复述] 以下为任务固定锚点与当前进度"]
for key in self.KEEP_KEYS:
if key in self.keep:
lines.append(f"{key}: {self.keep[key]}")
lines.append("当前 todo 状态:")
lines.extend(f"- {item}" for item in self.todo_state)
return "\n".join(lines)
def compact(self, messages: list[dict]) -> list[dict]:
"""超限压缩:系统提示保留,旧历史折叠成摘要,最近几轮原样保留。"""
system_msgs = [m for m in messages if m["role"] == "system"]
rest = [m for m in messages if m["role"] != "system"]
old, recent = rest[:-4], rest[-4:]
anchor = "\n".join(f"{k}: {v}" for k, v in self.keep.items())
resp = client.chat.completions.create(
model=os.environ["LLM_MODEL"],
messages=[
{"role": "system",
"content": "把待压缩历史折叠成要点摘要:保留结论、决策、"
"未完成事项与关键数字,不得新增或改动事实。"},
{"role": "user",
"content": f"固定锚点(原样保留):\n{anchor}\n\n"
f"待压缩历史:\n"
f"{[m['content'] for m in old]}"},
],
)
summary = resp.choices[0].message.content
return (system_msgs
+ [{"role": "user", "content": f"[历史摘要] {summary}"},
{"role": "user", "content": self.build_restatement()}]
+ recent)
budget = ContextBudget(total_budget=64_000)
budget.register("goal", "补齐订单系统的登录、评论、邮件通知三条链路的测试")
budget.register("constraints", "不得改动数据库结构;单次提交不超过 300 行")
budget.register("milestones", "登录链路测试已完成并通过")
budget.update_todos(["登录链路:已完成", "评论链路:进行中", "邮件通知:未开始"])
# 主循环里每步执行后:
# actions = budget.check() # 先取本步动作
# budget.charge("tool_result", tool_output) # 工具结果入上下文前记账
# budget.charge("history", assistant_reply) # 模型回复计入历史额度
# if "compact" in actions:
# messages = budget.compact(messages) # 逼近总预算,触发压缩
# if "restate" in actions:
# messages.append({"role": "user",
# "content": budget.build_restatement()})
几个工程要点:estimate_tokens 是粗估,生产环境换成官方 tokenizer 或计数接口;额度占比按任务类型调,抓日志型任务把 tool_result 调大,写码型任务把 history 调大;compact 的摘要调用本身有成本,触发阈值别压太低;restate 间隔按里程碑节奏校准,复述格式保持固定,方便写测试断言。
十一、harness 与模型要一起调:换便宜模型前先做回归
我最近反复琢磨的一个动向:智能体和它的 harness 是协同进化的关系。一套 harness 在某类模型上调优过之后——预算阈值、压缩提示词、复述格式,都是围着那类模型的行为习惯调的——直接拿这套 harness 产生的专家轨迹去训练小模型,性能反而可能受损;harness 与模型要一起调,不能拆开各动各的。
原因不难想通:专家轨迹里记录的是 harness 兜底之后的行为,模型学到的可能是被预算、压缩、复述层层修正过的产物;把这样的模型放回 harness 里,两层互相干扰,效果自然打折扣。
这个结论对换便宜模型省钱的决策影响很直接。换模型不是改一个 base_url 的事:旧 harness 的参数是为旧模型的行为调的,便宜模型对摘要的服从度、对复述的敏感度、工具调用的可靠性都可能不同。贵模型加好 harness,账单也许比便宜模型加旧 harness 更低——因为跑偏和返工才是最贵的 token。
所以换模型的标准动作是回归:准备一组固定的长任务,记录完成率、返工次数、token 总量和账单,新旧模型各跑一遍,按数据决策,而不是按单价决策。
十二、风险与合规:留痕与隐私边界
压缩丢信息要有审计留痕。每次压缩记录被压缩内容的归档位置、时间和摘要版本。摘要转述有误差,错误决策发生时要能回放原始上下文——压缩是搬家,不是销毁,这一点在出事故排查时值回所有工程量。
跨会话记忆有隐私边界。记忆里可能带着用户数据、代码片段甚至凭证残留:接入侧要做脱敏,存储侧要按租户隔离,保留期限和删除路径要明确写进设计;记忆文件本身还是攻击面,被注入的内容一旦落进记忆,会在后续会话里反复复活,记忆写入前要过一道和外部内容同样的审查。
合规底线:走正规渠道接入服务,遵守服务条款与数据合规要求;负载均衡和计费优化放在架构层做——多线路分摊、按任务复杂度路由模型——不碰任何绕过限制的方案。
十三、上线前检查清单
- 系统提示和工具定义过过秤,固定开销有定额;
- 工具结果进上下文前过尺寸闸门,超大结果卸载成指针;
- 压缩有明确触发阈值,目标、约束、里程碑走结构化通道保留;
- todo-state 复述格式固定、间隔定档,由 harness 生成;
- 跨会话记忆有交接文档格式,冷启动注入有容量上限;
- 每次压缩有审计留痕,原始内容可回溯;
- 记忆和日志完成脱敏,保留期限与删除路径明确;
- 至少跑过一轮完整的长任务回归,记录完成率、返工次数、token 总量;
- 换模型后重新评过全部阈值,未直接沿用旧参数;
- 预算耗尽的兜底行为明确:暂停并交接,而非静默截断继续跑。
十四、总结
长任务 Agent 的两类失效——上下文溢出与目标丢失——都能在 harness 层治:预算与卸载让大内容不进上下文,压缩让存量历史不失控,todo-state 复述把目标钉回注意力中心,跨会话记忆让冷启动有交接。账面上,先砍工具结果这个最大头,再压历史,成本和稳定性本就是一件事的两面。harness 与模型要一起调,换便宜模型前先跑回归,别让省下的单价被返工吃掉。模型接入和线路管理这块,我一直在用 4sapi(https://4sapi.com),多模型切换和计费对账都省心。四招里最容易被忽略的是复述和记忆这两个防跑偏动作,欢迎在评论区聊聊各自的长任务踩坑经历和压 token 的手段。