FrontierAgent 的上下文压缩与重复抑制
AgentPython架构长程 agent 跑着跑着就废了,这件事大家都见过,但很少有人把它拆成具体的失败模式再拿数据说话。
FrontierAgent 是 Apodex 前两天开源的 agent 运行时,翻代码时发现它把两个失败模式量化过:一个是上下文压缩触发得太晚,子 agent 被端点打死;另一个是压缩把旧结果换成占位符之后,模型开始一遍遍重发同一个搜索。
这两件事还是同一个环的两端。
项目概况
仓库里是一套 agent 运行时加终端产品加评测套件,两种自带工作流:ReAct 是单个有状态的 agent 自己查、读、写、跑命令;Agent Team 是一个协调者维护任务板,把独立的活拆给并行子 agent,收报告再合成。同一套引擎也驱动它们评测自家模型的 benchmark runner。
目录边界划得很清楚,frontier_agent/ 是通用循环、调度、注册表、AgentBus 和观察者,plugins/tools/ 是各类工具实现,workflows/ 是两条流水线的编排与提示词,apodex/ 是终端和 Docker 那一层,benchmarks/ 是评测。
13 万行 Python,79 个测试文件,Apache 2.0。不过提交只有 22 次,首次提交是 2026-08-24,也就是说这是内部仓库整理之后一次性倒出来的,不是在开源状态下长出来的。看的时候心里有数就行,代码质量与提交历史无关。
压缩的触发时机
压缩判定跑在一轮的末尾。麻烦在于,跑到那里的时候,这一轮的助手回复和工具结果都已经追加进历史了,所以下一次请求一定比刚发出去的那次大。只读「刚发出去的那次用了多少」,一轮就能从阈值下方直接迈过端点的硬上限。
这个说法在代码注释里有数据兜底:
对 51 个被
llm_error打死的子 agent 做过统计,51 个全都是最后一次成功请求刚好卡在 209,715 的触发线以下(中位数 207,803),紧接着发出一次中位数 264,743 token 的请求,被端点以 HTTP 400 拒掉,子 agent 连同它已经收集的全部报告一起丢失。
一轮能多出四万多 token,是因为推理内容会被回放成 <think> 写进历史。这个量级不是靠留余量能覆盖的。
所以触发条件改成投影下一次请求。两个数一起看:量表记录端点真实回报的 prompt_tokens,循环自己再算一个估算值,两者相除得到校准比例,用它去缩放这一轮末尾的估算,投影出下一次要发多少。真实值和投影值取大的那个跟阈值比。
比例被钳在 1.0 到 3.0。下限是要紧的那头,因为本地拿 tiktoken 的 cl100k_base 顶替服务端真分词器,实测系统性低估约 14%,而只有低估会害死一次请求;上限 3.0 纯粹是防呆。
还有一处细节,比例的分子分母必须来自同一次请求。注释里写了为什么不能就地用轮末历史去算分母:那份历史包含这一轮刚追加的工具结果,分母被撑大、比例被压小,恰好在最危险的那一轮低估风险。量表专门存一个 estimate 字段,全部理由就是这个。
阈值本身是上下文窗口的 0.8。这个系数被验证过:在长推理子集上跑 160 组,0.65 有 72.6% 的试次触发了压缩,0.8 是 44.4%,而得分 49.7% 对 50.0%,没有收益,只是多丢了历史、每次试验少搜 4%。注释的结论是把触发改成看即将发出的请求才是解决问题的那一步,余量从来不是瓶颈。
压缩本身是分层的:先压旧的工具结果,还不够才让模型摘要真实历史,受保护的结果不动。
重复检索的回滚
压缩解决了一个问题,又造出另一个。旧的工具结果被换成占位符之后,一个已经忘掉早先搜索结果的研究型 agent,最常见的反应不是换个思路,而是把一模一样的查询再发一遍。
这个也量化过:在 200 题的深度研究评测上,失败的试次平均每次重发约 37 个相同的 web_search 查询,跑 58 到 122 轮,而健康的试次约 22 轮就结束。相同的结果堆进历史、撑大上下文、把模型带进更长的游荡。子 agent 的 sub_max_turns 设成 100 时,整个额度可以就这么花光。
处理办法是一个观察者,在模型发出带工具调用的回复之后、这些工具真正执行之前介入。发现某个被追踪的工具调用重复了本轮循环里已经执行过的请求,就返回一个干预,把这条助手消息弹掉、这一轮重跑,并且不消耗 max_turns 的名额。在这些配置用的采样温度下,下一次生成几乎不会逐字重复同一个查询。
几个边界处理得比机制本身更值得看。
去重的粒度是整批
web_search 接受一个查询列表,整次调用算一个去重单元。[a, b] 和 [a, c] 是两次不同的搜索,所以模型逐步细化一批查询永远不会被回滚。注释说按单条查询做键会让触发频率高一个数量级,并且会把正常的查询演进也弹掉。
键里排除了 num、num_results 这类只改变返回多少条的参数,但保留 page、tbs、gl、hl、location——它们选中的是不同的结果集,翻页不能被读成重发。
终结工具的例外
回滚丢弃的是整条助手消息,所以这一批里的每个工具调用都跟着重复的那个搜索一起死。如果一批是 [web_search(重复的), submit_report(...)],报告就永久丢了,因为循环在执行工具之前就返回了,负责收尾的观察者根本看不到它。终结工具的名单取自循环自己的策略对象而不是硬编码,另加一个小兜底,覆盖那些不通过策略、而是通过观察者收尾的工具。
记账的时机
记账发生在拿到工具结果的时候,不是放行这一轮的时候。搜索失败是以普通结果字符串的形式回来的,[ERROR]: … 或者 No search results found.,is_error 是 False。而上游临时故障恰恰是重发同一个查询正确的场合,急着记账会把唯一该放行的情况挡掉。
回滚预算默认 5,是防活锁的阀门:连续 4 次回滚之后,下一个重复放行。计数器只有在一轮带了被追踪的工具调用且没有重复时才清零,预算耗尽的那次放行故意把它留在上限,模型必须完整跑过一轮干净的才能重新武装检测。
路径授权
文件访问是 fail-closed 的门。允许的前缀写死成列表,其余一律拒绝。
值得记一笔的是敏感文件名的匹配方式:credential、secret、password、token 这些词是按基本名的词级匹配的,不是拿整条路径做子串测试。注释说明了原因,子串测试会连带拒掉任何一个路径里恰好含这些词的目录,以及 tokenizer_config.json、secretary_notes.md、deck.keynote 这类文件名。这种误伤在真实项目里几乎必然发生。
沙箱三个目录的策略是 /inputs 只读、/workspace 读写、/outputs 受控读写,文件工具和 shell 工具共享同一套路径策略。交互式会话在写入、删除、装包和危险 shell 命令上再加一道批准,有些操作即使开了 --yes 也仍然拒绝,文件改动记了日志所以 /revert 能撤销。
任务板
Agent Team 的协调者维护一块任务板,add_task 和 update_task 的事件实时反映到界面侧栏。
板子的写操作会记进一个待排空队列,由流式观察者排空并发出事件帧。有个细节:每个操作在写入的那一刻就把所处阶段盖上去。因为在两段式的 agent-team 配置里,流式观察者没有挂在规划循环上,规划阶段写的操作要等执行循环跑起来才被排空,那时阶段已经翻到执行了。冻结写入时刻的阶段,才能让规划循环里的 add_task 显示成规划阶段。
测试名
翻这个仓库最省力的地方是测试名。它们是完整的句子,直接把设计意图写在名字里:
test_the_measured_death_scenario_now_triggerstest_the_scale_comes_from_one_snapshot_not_from_the_turn_end_historytest_an_over_stating_estimator_is_not_allowed_to_shrink_the_projectiontest_repeat_of_an_executed_query_is_rolled_backtest_pagination_is_not_a_duplicatetest_batch_composition_is_the_dedup_unittest_budget_lets_a_duplicate_through_and_stays_latchedtest_without_the_guard_the_same_repeats_burn_every_turn
最后那条尤其少见,它测的是把守卫拿掉之后问题会复现。多数项目只测「修好了」,不测「不修会怎样」,于是几年后没人知道那段代码还有没有用。
怎么跑起来
要 Python 3.12、uv 和一个 OpenAI 兼容的端点,Docker 可选。
git clone https://github.com/ApodexAI/FrontierAgent.git
cd FrontierAgent
uv sync --python 3.12 --extra dev
cp .env.example .env.env 里填端点,然后选一种工作流起终端:
uv run frontier-agent --mode react --cwd /path/to/project
uv run frontier-agent --mode agent_team --cwd /path/to/project评测走子进程 runner,每题一个隔离进程,支持断点续跑和单条重跑。
一点观察
两个失败模式其实是一个环:压缩为了保住上下文而丢掉旧结果,丢掉旧结果让模型重发搜索,重发的结果又把上下文撑大,逼出下一次压缩。单独看任何一个机制都像是补丁,合起来看才是同一件事的两端。
值得学的是它们处理这两件事的方式。两处都不是先设计再实现,而是先从真实失败里量出分布:51 个死亡样本的 token 中位数、37 个重复查询、58 到 122 轮对 22 轮,再据此定阈值和粒度,最后把结论钉成测试名。0.65 对 0.8 那组对照更能说明问题:想到了一个看起来合理的改法,跑了 160 组,发现没用,于是保留原值并把「测过、无效、原因是别的」写进注释。
顺带一提,这次是先用 codegraph 建了索引再读的,557 个文件建成 11,234 个节点、30,065 条边花了 875 毫秒。好处不在省几次搜索,而在于查一个符号会连带把调用者、被调用者和覆盖它的测试一起给出来。上面那批测试名就是这么撞见的,直接 grep 是想不到去搜它们的。