☰
深扒 Unreal Agent 异步内核:LLM 不等工具,40% token 到底是怎么省出来的?
2026/10/10 14:29:23 网站建设 项目流程

深扒 Unreal Agent 异步内核:LLM 不等工具,40% token 到底是怎么省出来的?

【免费下载链接】unreal-agentAsync-first agent harness项目地址: https://gitcode.com/gh_mirrors/un/unreal-agent

Agent 跑一趟真实任务,账单为什么总是比想象中高?多数人归咎于模型贵,但真正烧钱的往往是运行时本身的低效:模型在等工具时被迫"空转"输出占位文本,每一条 bash 结果都触发一轮全新的完整上下文重发,串行链式调用让同一段对话前缀被反复计费。Unreal Labs 开源的 Unreal Agent(一个 Go 实现的 async-first agent harness)给出的答案是:让模型在工具运行时彻底"睡觉",工具回来才叫醒它。官方在 Harbor 基准与社区评测中的口径是——相比 Codex 类同步框架可省约 40% 成本、对比同类异步方案约 20%,且性能无损。本文直接走进仓库源码,拆解这套省钱逻辑的三根支柱:协调器如何解耦模型推理与工具执行、异步化之后 token 的账怎么算、以及上下文构建器如何为 prompt cache 量身定制。

同步循环的钱都烧在哪

先明确"基线"是什么。经典的 ReAct 式 agent 循环是严格串行的:模型输出一个工具调用 → 等待工具执行 → 把结果拼进对话 → 再次调用模型。这带来三个结构性的浪费:

  1. 空等轮:长任务(如跑测试、装依赖)执行期间,模型必须被再次唤醒以"确认还在等",每次唤醒都是对全部历史上下文的一次完整重发与重新计费;
  2. 碎轮次:明明可以并行下发的多个独立命令被拆成多次串行调用,每多一轮,前缀 tokens 就多被计价一次;
  3. 缓存命中差:频繁在对话尾部追加"等待中"这类临时消息,污染了本来可以稳定命中的前缀缓存区。

Unreal Agent 的架构文档在 README.md 里定义了一组清晰的概念,把上述问题逐一拆解:Tool translator 只做校验与翻译(把模型调用翻译成可序列化的 Operation),真正的执行交给独立的 Operation manager;Session 是"只追加、可分叉"的历史账本;Coordinator 则是串起这一切的单线程事件循环。理解了这个分工,省钱逻辑就浮出水面了。

协调器:模型与工具之间的"防火墙"

核心实现在 harness/coordinator/loop.go。协调器运行一个极简的 select 事件循环,同时监听五类信号:

select { case <-ctx.Done(): // 全局取消 case received, open := <-inboxOutput: // 外部输入/控制消息 case received, open := <-operationUpdates: // 工具操作的状态变更 case <-heartbeat: // 工具长期运行时的心跳唤醒 case <-current.state.grace: // 结果聚合的宽限期到期 case received := <-modelResponses: // 模型异步响应 }

关键在requestModelResponse(同文件):模型请求被放进独立 goroutine,协调器持有cancelModel句柄,随时可以中断在途推理。而工具调用则走了完全不同的路径——Bash 的 translator 在 harness/tool/bash/bash.go 中只做两件事:校验参数、ctx.Submit(spec)生成一个带 UUID 的 Operation 描述符,然后立刻返回。真正的进程启动、输出捕获发生在 harness/operation/local_manager.go 的 actor 运行时里,通过primitiveEvents通道异步汇报进程退出、IO 读取等事件。

于是"模型"与"工具"之间被一层严格的契约隔开:translator 在事件循环里同步运行且不做任何 IO,Operation 在独立 actor 里异步执行。这层解耦带来第一个省钱红利——toolCallStatusesRequireModelResponse决定了是否还要叫醒模型:

func toolCallStatusesRequireModelResponse(statuses []sessionstore.ToolCallStatus) bool { for _, status := range statuses { if status.Status.Error != "" || len(status.Status.WaitingFor) == 0 { return true } } return false }

翻译过来的语义是:只要当前所有工具调用都还在等待对应的 Operation 完成(WaitingFor非空且无错误),协调器就根本不会发起新的模型请求。模型不需要在工具跑的时候说任何话——它睡到结果到达为止。这正是 preamble(harness/contextbuilder/prompts/preamble.md)里那句引导的落地实现:"Tool calls are asynchronous: each starts the moment you issue it and runs in the background... issuing one never blocks you and many run at once."

异步化之后,token 的账怎么算

光有"不等工具"还不够,还得防止两种新的浪费:结果一条条到达时触发连环唤醒,以及同批结果被拆进多轮。

第一道闸是输入记账。协调器状态里维护一对计数器:availableInputs(可交付给模型的待处理输入数)与deliveredInputs(已交付数)。外部输入、心跳、工具完成都会令前者递增;只有真正发起了一轮模型请求并收到响应,deliveredInputs才会追上。pendingInputs()非零才是触发新一轮的唯一依据之一。这意味着每一条新信息至多引发一轮推理,不存在"结果到了但没人消费"或"同一结果触发多轮"的泄漏。

第二道闸是grace 聚合窗口。processEvents里有两个 1 秒常量:

const ( toolCallRunGracePeriod = time.Second toolCallCompletionGracePeriod = time.Second )

当某个工具完成、但同一轮里还有其他兄弟调用在跑时,协调器启动toolCallCompletionGracePeriod宽限:在这 1 秒内到达的其余结果会被slurpChannel(1ms 空闲超时、最多 100 条)批量捞起,统一追加进同一次模型请求。配合availableInputs++的累加逻辑,一批并行工具的结果最终只触发一轮推理。这也是 preamble 反复强调"go wider with tool calls——they are cheap"的原因:并行度越高,聚合收益越大,轮次越少。

把两道闸合并看,异步化的 token 账就清晰了:轮次 = 输入批次数,而不是工具调用数。同步框架中 N 个串行工具 ≈ N 次完整上下文重发;异步框架中 N 个并行工具 ≈ 1 次上下文重发 + N 次增量结果。省下的不是推理 tokens,而是每次重发时全部前缀输入 tokens 的重复计费——当上下文已经积累到几万 tokens 时,这笔账非常可观。

prompt cache 友好性:为缓存而生的前缀设计

异步省轮次,但每一轮仍要重发完整上下文,所以 cache 命中率才是决定最终账单的放大器。Unreal Agent 的上下文构建器(harness/contextbuilder/builder.go)对此做了专门设计:

type builder struct { request llm.Request committedPrefix []llm.Item // 已提交的历史,只追加、永不改写 stagedSuffix []llm.Item // 本轮新增的待提交项 prefixTokens []prefixToken // 每个检查点的累计 tokens }

每次模型响应到达,AddModelResponse把输出原样 append 进committedPrefix;工具结果先进入stagedSuffix,直到Commit()才固化。前缀在相邻轮之间保持字节级稳定——新消息永远只出现在对话尾部,这是各家 prompt cache 命中硬前缀的完美形态。协作者的缓存设计在两层适配器里都有体现:

  • OpenAI Responses API(harness/llm/responsesapi/adapter.go):RequestOptions.CacheKey用会话 ID 的 SHA-256 生成prompt_cache_key写入请求体,同时支持通过CacheKeyPlacement把 key 放进自定义 header,兼顾 Anthropic 等把缓存键放在头部/上游路由的场景;
  • Anthropic Messages API(harness/llm/messagesapi/request.go):请求顶层携带cache_control: ephemeral,并暴露 5m/1h 的 TTL 配置,让长对话、跨长时间工具调用期间的缓存不失效。

基准适配器在 benchmarks/harbor/README.md 中把这条链路写得很直白:OpenRouter 请求"opt into automatic prompt caching with a one-hour TTL and carry the session id",保证不断增长的对话被缓存、且停留在同一上游——这正是长工具调用与多轮推理中间不击穿缓存的关键。

上下文利用率:压缩有预算,不牺牲在途工具

prompt cache 解决"重复付费",上下文压缩则解决"预算失控"。Unreal Agent 的压缩策略在 harness/contextbuilder/compaction.go 里是一套带预算的算法:

// This fixed budget is part of the replay contract. const compactionRetainedTokens = 20_000

当累计 tokens 超过模型配置的CompactionThreshold(默认取 context window 的一半,见 harness/settings/settings.go)时,构建器在保留 2 万 tokens 的硬预算内寻找最后一个完整轮次检查点,把更早的历史交给一个专门用于压缩的 summarizer 请求,产出手写交接摘要(prompt 见 harness/contextbuilder/prompts/compaction.md)。这里有两个易被忽视的细节:

一是在途工具调用不丢失。carryForwardContext会扫描被压缩掉的前缀,把所有仍在运行的 tool call 连同其最新的 running 结果"搬运"进新上下文——模型醒来时依然知道哪些工具还挂着;二是压缩本身也有 cache 收益,prefixTokens检查点机制让新摘要替换旧历史后,剩余前缀依然稳定。

token 账的透明度也写进了数据模型:llm.TokenUsage(harness/llm/model.go)把InputTokens、CachedInputTokens、CacheWriteInputTokens、OutputTokens、ReasoningTokens分开统计,基准轨迹(benchmarks/harbor/src/harness_harbor/trajectory.py)逐轮记录 prompt/cached/cache_write/completion/reasoning 五类数值——省在哪、省多少,都可以被精确审计。

40% 的账,其实由三笔组成

回到标题的数字。把源码里的机制折算成账单,40% 的节省可以拆成三笔:

  1. 空等轮清零:模型只在有实质输入时才被唤醒,工具运行期间彻底休眠,消灭了同步框架里"等工具"的占位推理与整轮上下文重发;
  2. 轮次合并:并行工具结果经 1 秒 grace 窗口聚合,N 次串行重发收敛为一次批量增量,前缀输入 tokens 的重复计费按并行度等比例下降;
  3. 缓存命中率抬升:稳定前缀 + 会话级缓存键 + 长 TTL,让每一轮重发的绝大部分输入 tokens 以缓存价结算,而非全价。

值得注意的是,社区文章中"40% vs Codex、20% vs Pi"这类数字,在仓库内对应的可验证产物是 token 级别的分项统计,而非直接标价——trajectory.py 的注释写得很严谨:"Costs are unknown: runner usage contains tokens, not billed amounts."。换句话说,Unreal Agent 的贡献不是发明了更便宜的模型,而是把 token 的每一次重复计费都设计掉了:让 LLM 不空转、让轮次变粗、让前缀可缓存。对任何想把 agent 账单打下来的工程团队,这三条设计都值得直接抄进自己的 harness。

【免费下载链接】unreal-agentAsync-first agent harness项目地址: https://gitcode.com/gh_mirrors/un/unreal-agent

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询