Joye Dev

Back

Agent eval:从提示形状一致到模型路由成本可比较

结论先行:#5107 把主 eval 从只有 pass/fail 的黑盒结果推进成一个可比较的模型路由观测面:dev eval route 返回解析后的 model id 和整轮 token usage,promptfoo provider 在独立的分析价格表上计算 USD 成本,并把 token、cost、la…


结论先行:#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 能力迭代中的两个失真问题:

  1. eval 看起来通过,但实际测的是一份被复制或正则抓取出来的旧 prompt。生产 turn 改了 profile、用户上下文或 creation directive,eval 仍然可能绿着;
  2. 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 98468b0287d5f6aae9d3345974183738272f46c1worker prompt context、image tool contract、trajectory eval将用户本轮上传内容投影为 submitted/upload,与 context 场景分开;新增 argExcludes,断言刚上传的图片进入 referenceAttachmentIds,历史 asset 不得泄漏到 referenceAssetIds。
eval 直接依赖生产 prompt#5106,2026-07-23T03:58:42Z,merge ed40e744cd3fec0761dc0ad3e8e5a3d5535a734dagent-eval suggestion provider、workspace package boundary将 SUGGESTION_GENERATION_PROMPT 从 worker package 的 export 导入,删除 readFileSync + 正则抽取;prompt 改名或重排时,eval 不再默默保留一份旧文本。
生产/eval turn 形状收敛#5108,2026-07-23T04:11:16Z,merge fb75267e11200ff14908fd280d96d58ae8a3510aAgentProfile、生产 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 aeacbd7e3d0ddb117b3656e1ebf3358781b4b416eval route response、promptfoo provider、prices.tsroute 返回 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 --> S
plaintext
  1. **输入和测试入口:**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,不等同于后端持久化集成测试。
  2. 用户 turn 的结构化投影:#5056 的 preparePromptContextMessages 在每个 user message 上分别注入 context、brand 和 submitted;submitted 只描述 name、mediaType、sandbox path,具体能否作为某个工具的输入由工具 schema 约束。这样用户说“这个图”时,模型有机会根据消息文本和本轮 upload 一起解析,而不是默认命中当前场景或历史 output。
  3. system prompt 的共同组装:#5108 将固定顺序封装进 agentProfile.ts。生产 Agent 在 streamText 前调用 helper;eval route 在 generateText 前调用同一 helper。生产侧的 unattendedPlatformTask 通过参数进入,eval 不传该参数,因此共享结构但不伪造生产环境专属 directive。
  4. **多步 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 能看到用户可见问题。
  5. **成本报告:**provider 将 inputTokens/outputTokens 映射为 promptfoo 的 tokenUsage,并调用 agent-eval/prices.ts 的 costUsd。价格表明确属于分析 harness,不共享 Go billing price;当前 #5107 的 cost 口径只覆盖 chat model,image-composer 和 tool-repair 的辅助调用不进入该比较。
  6. 并行的 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,再讨论哪个模型更划算。

我会怎么吸收#

  1. 评估生产行为时,优先寻找真实运行路径中的可复用边界函数;不要在 eval 里复制 prompt 片段,也不要用正则从源码抓取文本。
  2. 把用户输入的来源建模成结构化 envelope,例如 submitted material 与 viewed context 分开,再用行为级正向/反向断言保护引用选择。
  3. 多步 Agent 的成本指标应在完整 turn response 处汇总,provider 层只负责把 usage 映射到报告格式;辅助调用是否计入要写成明确的口径,而不是默认混入。
  4. 把分析价格和在线 billing 分开,保留 exact model id、缺价失败和价格来源注释;这样新 provider 不会静默生成错误的 0 成本。
  5. 对“生产/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 字段。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back