上下文预算不是省 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 总量和账单,新旧模型各跑一遍,按数据决策,而不是按单价决策。

十二、风险与合规:留痕与隐私边界

压缩丢信息要有审计留痕。每次压缩记录被压缩内容的归档位置、时间和摘要版本。摘要转述有误差,错误决策发生时要能回放原始上下文——压缩是搬家,不是销毁,这一点在出事故排查时值回所有工程量。

跨会话记忆有隐私边界。记忆里可能带着用户数据、代码片段甚至凭证残留:接入侧要做脱敏,存储侧要按租户隔离,保留期限和删除路径要明确写进设计;记忆文件本身还是攻击面,被注入的内容一旦落进记忆,会在后续会话里反复复活,记忆写入前要过一道和外部内容同样的审查。

合规底线:走正规渠道接入服务,遵守服务条款与数据合规要求;负载均衡和计费优化放在架构层做——多线路分摊、按任务复杂度路由模型——不碰任何绕过限制的方案。

十三、上线前检查清单

十四、总结

长任务 Agent 的两类失效——上下文溢出与目标丢失——都能在 harness 层治:预算与卸载让大内容不进上下文,压缩让存量历史不失控,todo-state 复述把目标钉回注意力中心,跨会话记忆让冷启动有交接。账面上,先砍工具结果这个最大头,再压历史,成本和稳定性本就是一件事的两面。harness 与模型要一起调,换便宜模型前先跑回归,别让省下的单价被返工吃掉。模型接入和线路管理这块,我一直在用 4sapi(https://4sapi.com),多模型切换和计费对账都省心。四招里最容易被忽略的是复述和记忆这两个防跑偏动作,欢迎在评论区聊聊各自的长任务踩坑经历和压 token 的手段。