Agent eval:从提示形状一致到模型路由成本可比较
结论先行:#5107 把主 eval 从只有 pass/fail 的黑盒结果推进成一个可比较的模型路由观测面:dev eval route 返回解析后的 model id 和整轮 token usage,promptfoo provider 在独立的分析价格表上计算 USD 成本,并把 token、cost、la…
source_automation: “voyager-merged-pr” run_date: “2026-07-23” anchor_pr_number: 5107 pr_number: 5107 pr_title: “feat(agent-eval): report per-turn token cost in the main eval” pr_url: “https://github.com/adastralab-ai/voyager/pull/5107 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-23T04:47:06Z” merge_commit: “aeacbd7e3d0ddb117b3656e1ebf3358781b4b416” main_sha_at_read: “aeacbd7e3d0ddb117b3656e1ebf3358781b4b416” modules: [“agent-eval”, “backend/workers/agent/src/routes/agentEval.ts”, “backend/workers/agent/src/agentProfile.ts”, “backend/workers/agent/src/agent.ts”, “backend/workers/agent/src/promptContext.ts”, “backend/workers/agent/src/prompts.ts”] files_changed: 3 learning_tags: [“agent-eval”, “prompt-fidelity”, “production-eval-parity”, “turn-context”, “prompt-source-of-truth”, “token-usage”, “cost-observability”, “model-routing”, “promptfoo”, “trajectory-eval”, “model-provider”] business_line: “Agent eval 与生产 turn 的提示一致性和成本可观测性” related_prs: [5056, 5106, 5108] line_stage: “输入来源结构化 -> eval 直接复用生产 prompt -> suggestion eval 直接导入生产 prompt -> 主 eval 暴露整轮 token 与模型成本” open_questions: [“totalUsage 与 prices.ts 当前按普通 input/output token 计价,是否需要单独记录 cached input tokens 或不同 provider 的缓存价格。”, “eval route 在缺少 usage 或 modelId 时会省略成本字段,失败 turn 也只返回错误与轨迹;是否需要把部分用量和成本缺口显式标成失败指标。”, “主 eval 的 cost 只覆盖 chat model,image-composer 与 tool-repair 的辅助调用被排除;这是否要同时提供一个面向真实用户总成本的第二口径。”, “新 provider/model 的 exact model id、价格表和 promptfoo 输出列目前主要靠手工 Test Plan 验证,是否应增加 route/provider 回归测试。”, “共享 system-message helper 只保证生产 DO turn 与 dev eval 的前置 prompt 形状一致,后续 prepareStep 的模式切换、工具真实执行和 Go/R2 持久化仍由独立测试覆盖。”] feishu_doc_url: null github_commit: “3e5ef6d21059442dbae384210af6745aa3ccdfcb”#
结论先行:#5107 把主 eval 从只有 pass/fail 的黑盒结果推进成一个可比较的模型路由观测面:dev eval route 返回解析后的 model id 和整轮 token usage,promptfoo provider 在独立的分析价格表上计算 USD 成本,并把 token、cost、latency 放进报告。它真正解决的前提由 #5108 和 #5056 铺好:生产 Agent turn 与 eval 必须共享同一套 system-message 组装,用户本轮提交的文件必须和当前查看场景结构分离。业务计费仍在 Go/credits 边界之外,当前 cost 是评估口径,不是账单事实。
业务线概览#
这条线解决的是 Agent 能力迭代中的两个失真问题:
- eval 看起来通过,但实际测的是一份被复制或正则抓取出来的旧 prompt。生产 turn 改了 profile、用户上下文或 creation directive,eval 仍然可能绿着;
- standard 与 ultra 都能完成任务,却没有每轮 token 和美元成本,无法回答模型路由最核心的产品问题:更贵的模型带来的质量收益是否值得成本。
本次学习围绕四个已经进入 origin/main 的 PR,代码关系集中在 Agent worker 的输入/提示边界和 agent-eval 的执行/报告边界:
- #5056 先把本轮上传内容从当前查看场景和历史生成物中分离出来,并用正向/反向 trajectory assertion 保护指代解析;
- #5106 让 suggestion eval 通过 workspace package export 直接导入 worker 的已发布 prompt,移除读取源码再用正则抽取的平行实现;
- #5108 把生产 Durable Object turn 和 dev eval route 的 leading system messages 抽成同一个 helper;
- #5107 以 #5108 的一致性边界为基础,向主 eval 返回整轮 usage 和 model id,并在 promptfoo 层计算可比较的 cost。
本线的范围到“评估是否忠实、成本是否可观测”为止,不延伸成在线计费系统、权限系统或完整真实用户成本核算。
今日锚点#
- PR:#5107 feat(agent-eval): report per-turn token cost in the main eval ↗
- **作者:**Horcrux / magicismight
- **Merge:**2026-07-23T04:47:06Z,即 Australia/Melbourne 2026-07-23 14:47:06 AEST
- **Merge commit:**aeacbd7e3d0ddb117b3656e1ebf3358781b4b416,已验证可达当前 origin/main
- **锚点改动:**3 个文件,37 additions / 3 deletions;涉及 agent-eval provider、分析价格表和 agent eval route
选择 #5107 的理由是它是当天高价值且未学习过的合并 PR,并且它不是孤立的数值字段追加:#5108 提供了生产/eval prompt parity 的前置边界,#5056 提供输入来源与行为断言的基础,#5106 则展示了同一条 eval 可信度原则在 suggestion 子评估中的并行落地。四个 PR 共享 agent-eval、worker prompt 和真实 turn 执行边界,能形成从“测的是什么”到“如何比较成本”的完整演进。
演进时间线#
| 阶段 | PR | 改变的层 | 代码证据与新增能力 |
|---|---|---|---|
| 输入来源可验证 | #5056 ↗,2026-07-22T08:51:08Z,merge 98468b0287d5f6aae9d3345974183738272f46c1 | worker prompt context、image tool contract、trajectory eval | 将用户本轮上传内容投影为 submitted/upload,与 context 场景分开;新增 argExcludes,断言刚上传的图片进入 referenceAttachmentIds,历史 asset 不得泄漏到 referenceAssetIds。 |
| eval 直接依赖生产 prompt | #5106 ↗,2026-07-23T03:58:42Z,merge ed40e744cd3fec0761dc0ad3e8e5a3d5535a734d | agent-eval suggestion provider、workspace package boundary | 将 SUGGESTION_GENERATION_PROMPT 从 worker package 的 export 导入,删除 readFileSync + 正则抽取;prompt 改名或重排时,eval 不再默默保留一份旧文本。 |
| 生产/eval turn 形状收敛 | #5108 ↗,2026-07-23T04:11:16Z,merge fb75267e11200ff14908fd280d96d58ae8a3510a | AgentProfile、生产 Agent turn、dev eval route | 新增 buildTurnSystemMessages,固定 base -> profile -> user context -> optional directive -> unattended note 的顺序,DO turn 与 eval route 共享 helper;返回 profile 仍供 prepareStep 的模式切换使用。 |
| 成本可比较 | #5107 ↗,2026-07-23T04:47:06Z,merge aeacbd7e3d0ddb117b3656e1ebf3358781b4b416 | eval route response、promptfoo provider、prices.ts | route 返回 chatModel.id 和 generateText 的 totalUsage;provider 只有在 input/output usage 齐全时才生成 tokenUsage,并用 exact model id 查表计算 USD cost,缺价直接抛错。 |
**当前阶段判断:**这条业务线已经从“用 eval 检查工具轨迹”推进到“让 eval 尽量成为生产 turn 的可执行镜像,并输出模型路由的质量/延迟/成本对照”。它还没有达到完整的账单级成本、缓存计费和辅助模型总成本治理阶段。
当前架构与数据流#
flowchart LR
A["agent-eval case vars"] --> B["promptfoo AgentEvalProvider"]
B --> C["POST /api/internal/dev/eval"]
C --> D["preparePromptContextMessages"]
C --> E["buildTurnSystemMessages"]
D --> F["submitted uploads + viewed context"]
E --> G["base + profile + user context + directives"]
F --> H["generateText real model/tool schema"]
G --> H
P["production Agent DO turn"] --> E
H --> I["chatModel.id + totalUsage + toolSequence + finalText"]
I --> B
B --> J["promptfoo tokenUsage + cost + latency"]
K["agent-eval/prices.ts"] --> J
S["suggestion eval"] --> L["@repo/agent-worker/prompts export"]
L --> Splaintext- **输入和测试入口:**agent-eval 的 YAML case 提供 history、creation、uploads、message 和 mockTools。AgentEvalProvider 调用 worker 的 dev-only /api/internal/dev/eval;route 让真实模型、system prompt、tool schema、skills 和 sandbox 工具运行,Go/R2-backed 工具用 mock 或空对象替代。这个边界测的是 prompt-driven tool trajectory,不等同于后端持久化集成测试。
- 用户 turn 的结构化投影:#5056 的 preparePromptContextMessages 在每个 user message 上分别注入 context、brand 和 submitted;submitted 只描述 name、mediaType、sandbox path,具体能否作为某个工具的输入由工具 schema 约束。这样用户说“这个图”时,模型有机会根据消息文本和本轮 upload 一起解析,而不是默认命中当前场景或历史 output。
- system prompt 的共同组装:#5108 将固定顺序封装进 agentProfile.ts。生产 Agent 在 streamText 前调用 helper;eval route 在 generateText 前调用同一 helper。生产侧的 unattendedPlatformTask 通过参数进入,eval 不传该参数,因此共享结构但不伪造生产环境专属 directive。
- **多步 turn 和结果协议:**route 用 generateText 跑完整 turn,沿用 MAX_STEP_COUNT 和 prepareStep 的 profile switch;成功时以 response JSON 返回所有步骤拼接的 finalText、toolSequence、resolved modelId、整轮 input/output tokens。toolSequence 仍作为 eval metadata,choice card 额外拼进 provider 的 output,保证 rubric 能看到用户可见问题。
- **成本报告:**provider 将 inputTokens/outputTokens 映射为 promptfoo 的 tokenUsage,并调用 agent-eval/prices.ts 的 costUsd。价格表明确属于分析 harness,不共享 Go billing price;当前 #5107 的 cost 口径只覆盖 chat model,image-composer 和 tool-repair 的辅助调用不进入该比较。
- 并行的 suggestion 子评估:#5106 让 suggestion/provider.ts 直接 import worker package 的 SUGGESTION_GENERATION_PROMPT,并在 package.json 暴露 ./prompts。它和主 eval 的 response 形状不同,但遵循同一条 source-of-truth 原则:eval 依赖可导入的生产 prompt,而不是复制文本。
关键代码#
1. #5056:把本轮提交内容与当前场景分成两个语义源#
来源:#5056 ↗ diff,backend/workers/agent/src/promptContext.ts:87-117。
const tags = message.parts.filter(isAgentAttachmentPart).map((part) => {
const { name, mediaType, attachmentId } = part.data;
const path = SANDBOX_ATTACHMENTS_DIR + "/" + attachmentId;
const tag =
" <upload name=\"" + escape(name) + "\" mediaType=\"" +
escape(mediaType) + "\" path=\"" + escape(path) + "\" />";
return tag;
});
return {
...message,
parts: [
{ type: "text", text: "<submitted> ... </submitted>" },
...message.parts,
],
};plaintext代码验证事实:context 注入仍然由前面的 injectDocumentContextParts 完成,submitted 只声明本轮材料,不在共享块上塞入某个工具的 reference 能力位。#5056 同时在 agent-eval/cases/image/image-mode.yaml 增加 referenceAttachmentIds 正向断言和 referenceAssetIds 反向断言。设计取舍是保留通用输入模型,把 tool-specific constraint 留给 image_generation schema。
2. #5108:固定 system-message 顺序并返回 mode profile#
来源:#5108 ↗ diff,backend/workers/agent/src/agentProfile.ts:54-87。
const systemMessages: ModelMessage[] = [
{ role: "system", content: buildBasePrompt(opts.skills) },
{ role: "system", content: profile.profilePrompt },
{
role: "system",
content: buildUserContextPrompt(opts.clientContext, opts.imageModelId),
},
...(profile.directivePrompt
? [{ role: "system", content: profile.directivePrompt }]
: []),
...(opts.unattendedPlatformTask
? [{ role: "system", content: UNATTENDED_PLATFORM_TASK_PROMPT }]
: []),
];
return { profile, systemMessages };plaintext代码验证事实:helper 同时返回 profile 和消息数组。profile 不是多余返回值,eval/生产的 prepareStep 在 mode switch 时仍调用 swapProfileMessages;这保留了 #4637 以来的 mode-scoped profile 语义,同时把 initial turn 的组装 owner 收回到一个位置。
3. #5108:生产 turn 和 eval route 调用同一个 owner#
来源:#5108 ↗ diff,backend/workers/agent/src/agent.ts:1260-1297 与 backend/workers/agent/src/routes/agentEval.ts:340-365。
// Agent DO turn
const { profile, systemMessages } = buildTurnSystemMessages({
creation,
skills,
declared,
clientContext: effectiveClientContext,
imageModelId,
unattendedPlatformTask: isUnattendedPlatformTask,
});
const result = streamText({
model: chatModel.model,
messages: [...systemMessages, ...toModelMessagesResult],
allowSystemInMessages: true,
});plaintext// dev eval route
const { profile, systemMessages } = buildTurnSystemMessages({
creation,
skills,
declared: creation !== undefined && !parsed.data.creationStored,
clientContext: { language: "en-US", timezone: "Asia/Singapore" },
imageModelId,
});
const result = await generateText({
model: chatModel.model,
messages: [...systemMessages, ...messages],
allowSystemInMessages: true,
});plaintext设计点不是把两条执行路径完全合并:生产仍是 streamText、拥有真实 attachment mount 和 soft interrupt,eval 仍用 hermetic tool boundary。合并的是最容易产生静默漂移的 prompt assembly boundary;不同的环境能力继续由调用方显式传入。
4. #5107:在 route response 建立整轮 usage 契约#
来源:#5107 ↗ diff,backend/workers/agent/src/routes/agentEval.ts:361-399。
const chatModel = provider.chatModel(agentModelId);
const result = await generateText({
model: chatModel.model,
messages: [...systemMessages, ...messages],
// ...
});
return Response.json({
agentModelId,
modelId: chatModel.id,
usage: {
inputTokens: result.totalUsage.inputTokens,
outputTokens: result.totalUsage.outputTokens,
},
toolSequence,
finalText: result.steps.map((step) => step.text).filter(Boolean).join("\n\n"),
});plaintext代码验证事实:使用 totalUsage 而不是最后一个 step 的 usage,因而覆盖多步 Agent turn;modelId 来自 provider 解析后的 chatModel,而不是从 eval case 名称猜。当前 route 的 error response 只返回 error、agentModelId 和 toolSequence,不返回部分 usage。
5. #5107/#5106:成本和 prompt source 都在 eval 层显式化#
来源:#5107 ↗ diff,agent-eval/provider.ts:152-194、agent-eval/prices.ts:23-45;#5106 ↗ diff,backend/workers/agent/package.json:7-10。
const { inputTokens, outputTokens } = data.usage || {};
const haveUsage =
typeof inputTokens === "number" && typeof outputTokens === "number";
return {
output,
metadata: { toolSequence },
...(haveUsage
? {
tokenUsage: {
prompt: inputTokens,
completion: outputTokens,
total: inputTokens + outputTokens,
},
}
: {}),
...(haveUsage && data.modelId
? { cost: costUsd(data.modelId, inputTokens, outputTokens) }
: {}),
};plaintext// suggestion/provider.ts
import { SUGGESTION_GENERATION_PROMPT } from "@repo/agent-worker/prompts";
// backend/workers/agent/package.json
"exports": {
".": "./src/index.ts",
"./prompts": "./src/prompts.ts"
}plaintext这里有两个故意的 fail-closed 选择:usage 不完整时不伪造 0 成本;model id 不在价格表时 costUsd 抛错,提醒维护者补 exact key。prices.ts 还明确声明它是 analysis surface,不与 Go billing price 共用,避免评估 harness 和账务系统被同一份价格更新节奏绑死。
工程取舍#
- **生产/eval 复用边界:**共享 buildTurnSystemMessages 消除了两份 leading prompt 的平行维护,且仍保留 system message 的分段顺序;这比把所有 prompt 拼成一条字符串更容易维持 profile、context 和 Anthropic cache breakpoint 的边界。代价是 agentProfile.ts 承担了更多组装依赖,新增 system message 时必须判断它是否适用于 production、eval 或 unattended task。
- 输入来源结构化 vs 工具耦合:#5056 让 submitted upload 只描述来源和路径,image_generation 再通过 referenceAttachmentIds 的 schema 约束 PNG/JPEG/WebP。这样同一输入块可以被不同工具消费,代价是模型仍需要依据用户文本和工具描述完成目标选择,不能靠一个全局 attachment flag 彻底消除歧义。
- **整轮 usage vs step 级 attribution:**totalUsage 适合回答 standard/ultra 一轮花费多少,也避免只采集最后一步;但它丢失了每个 step、每个 tool-repair 或 image-composer 子调用的成本归因。#5107 通过排除辅助调用保持 routing 对比口径稳定,却没有回答“真实用户完成一次任务总共花了多少”。
- **分析价格表 vs billing 共享:**prices.ts 维护 exact model id 并在缺价时抛错,能把 provider 接入遗漏变成显式失败;独立于 backend/go/internal/credits 的代价是价格可能滞后、缓存 token 计价可能不一致,报告不能直接作为账单依据。
- **评估忠实性 vs hermetic 可重复性:**dev eval route 使用真实 model、prompt、tool schemas 和 skills,但 Go/R2-backed 工具被 stub。它对 prompt/trajectory 回归很有价值,运行成本和环境依赖低;同时不能证明生产持久化、权限、网络失败或资源上传链路。
- 测试策略:#5056 添加 promptContext 单测和 image trajectory 的正负断言,覆盖了“指向本轮 upload 而非历史 asset”的行为;#5108 PR Test Plan 运行了 agent worker 323 个用例并建议抽跑 eval。#5107/#5106 的验证重点仍是本地启动 worker、执行 promptfoo 和检查报告列,当前没有在本次只读学习中重新运行这些命令。
和最近学习记录的关系#
- 2026-07-15 的 #4637 mode-scoped agent profiles ↗ 已经把 AgentProfile 作为持久 mode、turn-scoped directive 和 replay 语义的 owner;本次 #5108 没有重新发明 mode state,而是复用这个 owner 解决 initial prompt assembly 的 production/eval 漂移。
- 2026-07-17 的 #4895 replay burst batching ↗ 和 2026-07-19 的 #4844 soft interrupt follow-ups ↗ 关注运行时恢复、终态和用户可见进度;本线把相同的“显式边界、保留完整语义、不要依赖隐式时序”原则迁移到 eval harness:输入来源、system message 顺序、整轮 usage 都变成可观察契约。
- 2026-07-22 的 #5015 workspace feature catalog ↗ 学习的是 control plane 的 canonical source 和生成 projection;本次没有把它与 Agent eval 硬拼为同一业务线,但两者共享一个可迁移方法:把跨层隐式约定提升为有 owner 的契约,并让 drift 在代码或 CI 中暴露。
- 这次新增的视角不是“又一个 Agent prompt 修复”,而是从单个行为回归走向评估系统本身的可信度和成本解释力:先保证测的是生产 turn,再讨论哪个模型更划算。
我会怎么吸收#
- 评估生产行为时,优先寻找真实运行路径中的可复用边界函数;不要在 eval 里复制 prompt 片段,也不要用正则从源码抓取文本。
- 把用户输入的来源建模成结构化 envelope,例如 submitted material 与 viewed context 分开,再用行为级正向/反向断言保护引用选择。
- 多步 Agent 的成本指标应在完整 turn response 处汇总,provider 层只负责把 usage 映射到报告格式;辅助调用是否计入要写成明确的口径,而不是默认混入。
- 把分析价格和在线 billing 分开,保留 exact model id、缺价失败和价格来源注释;这样新 provider 不会静默生成错误的 0 成本。
- 对“生产/eval 一致”保持边界化理解:复用 prompt assembly 不代表复用所有 side effect,真实 persistence、权限和网络故障仍需要独立 integration/contract tests。
边界、风险、未解问题#
- 成本不是账单:#5107 的 prices.ts 明确是 eval analysis surface;它不读取 Go billing price,也没有把 credit、缓存折扣或辅助模型开销带入结果。把 promptfoo 的 cost 直接用于收费或 workspace entitlement 会越过当前代码边界。
- **缓存 token 口径:**route 只传 inputTokens/outputTokens,prices.ts 对 input 使用单一价格。若 AI SDK/provider 返回 cached input 或不同缓存价,当前报告可能适合横向比较但不等于实际 provider invoice。
- **缺失 usage 的可见性:**provider 在 usage 不完整时省略 tokenUsage/cost;这避免 fabricated zero,但也可能让报告只少一列而不是让 case 明确失败。需要决定“不可观测成本”是否应成为 eval error。
- **失败 turn 的成本盲区:**route 的 502 response 不回传部分 usage,工具失败或模型终止前已经产生的 token 不进入当前 cost。长 turn 的失败成本和重试成本仍待单独设计。
- **辅助调用边界:**image-composer、tool-repair 可能是完成真实任务的重要调用,但被刻意排除在 standard-vs-ultra chat model comparison 外。应同时维护 routing comparison 与 user-total-cost 两种指标,避免产品问题被单一数字替代。
- **新模型接入:**costUsd 依赖 provider 返回的 exact model id;Gateway alias、模型版本升级或输出 id 变化都可能触发缺价。#5107 目前靠价格表 + 手工运行验证,没有看到针对未知 model、usage 缺字段和 promptfoo cost 字段的自动化回归。
- **环境边界:**dev eval route 是 AGENT_LOCAL_DEV gated endpoint,Go/R2 工具使用 mock。即使 promptfoo 绿了,也不能推出生产 stream、attachment mount、权限和持久化路径无回归。
- **未验证项:**本次只读检查了 origin/main、merge commit、PR diff 和现有测试代码,没有运行 pnpm test、promptfoo eval 或本地 agent worker;报告中的测试事实来自已合入代码和 PR Test Plan,应与独立执行结果分开看。
候选说明#
- **窗口:**按 Australia/Melbourne 计算,今日窗口为 2026-07-22T14:00:00Z 至 2026-07-23T14:00:00Z;当天已有足够高价值候选,因此没有扩大到 7 月 22 日本地日期之前的更早窗口。
- **去重:**读取了 /Users/joye/.codex/automations/voyager-merged-pr/study-log.jsonl;已有锚点截至 #5015,本次未再次选择任何已学习锚点。
- **候选比较:**当天的 #4991/#4992 形成字体 SVG 与 SVG rasterization worker 的媒体处理线,但更偏独立 worker/部署基础;#5026 是 design/image intent routing 的行为线;#5103 是 eval case 描述清理;#5107 直接触及主 eval 的 model-routing 价值,且能串起 #5056、#5106、#5108 的 runtime/eval 关系。
- 排除证据:#5067 标题与输入来源结构化高度相关,但其 baseRefName 是 cy-agent-message-provenance,merge commit 3598b81c5982152850ffcbca4013dd95c1ccbe92 当前不在本地 origin/main 对象图中,因此没有把它作为关联 PR;#5037 等同类中间分支 PR 也遵守同一规则。
- 最终选择:#5107 能自然表达“先让 eval 忠实于生产,再让结果可比较”的阶段推进,相关 PR 数量为 3,没有为了凑数量加入只共享 agent 关键词而没有共同代码边界的 PR。
GitHub 文档#
- **报告文件:**outputs/voyager-daily-pr-study/2026-07-23-pr-5107-agent-eval-turn-cost-prompt-fidelity.md
- Voyager PR:#5107 ↗
- **阅读基线:**origin/main at aeacbd7e3d0ddb117b3656e1ebf3358781b4b416;锚点 commit 同为 aeacbd7e3d0ddb117b3656e1ebf3358781b4b416
- **报告 GitHub commit:**3e5ef6d21059442dbae384210af6745aa3ccdfcb(首次写入报告的 commit);最终推送 HEAD 记录在外部 study-log 的 githubCommit 字段。