From fcf1822233ddbd1cb7e5120df341c37e93d53c15 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 08:18:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E9=85=8D=E6=96=B9=E7=9A=84=20analy?= =?UTF-8?q?ze=20=E8=B5=B0=20traced=20=E7=BD=91=E5=85=B3=E5=B9=B6=E5=9B=9E?= =?UTF-8?q?=E8=B4=A6=E7=94=A8=E9=87=8F=20=E2=80=94=E2=80=94=20=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E9=97=AE=E6=A8=A1=E5=9E=8B=E4=B8=8D=E8=8A=B1=E9=92=B1?= =?UTF-8?q?=E7=9A=84=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 装配层从前传的是裸 `deps.modelGateway`:`createModelGateway` 自己不做 `addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了, 只有 analyze 漏了。后果两条:配方里 analyze 烧的 token 一个数都不计入活动预算 (预算这道闸对它形同虚设);时间轴上也不产生模型 span,「这一单问了几次模型」 看不见它。而 spec §5.2 白纸黑字写着「用量计入预算」。 现 `createAnalyzeGateway(tracedGateway, onAgentUsage)`,网关内**两次** generate (含重问那一次)的用量各记一笔 —— 失败的调用不是免费的。记账不吞异常: 记不上账等于预算失效,宁可当场停下由配方留痕交人。 debt.md 里那半句「它烧 token、计预算」当时是假的,改老实。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../src/app/__tests__/analyze-gateway.test.ts | 49 +++++++++++++++++++ apps/api/src/app/wiring/analyze-gateway.ts | 23 ++++++++- apps/api/src/app/wiring/engines.ts | 8 ++- docs/debt.md | 5 +- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/__tests__/analyze-gateway.test.ts b/apps/api/src/app/__tests__/analyze-gateway.test.ts index 7759180..2340c4a 100644 --- a/apps/api/src/app/__tests__/analyze-gateway.test.ts +++ b/apps/api/src/app/__tests__/analyze-gateway.test.ts @@ -50,4 +50,53 @@ describe('analyze 网关', () => { await expect(gateway.run(call)).rejects.toThrow(/出参不成形/); expect(n).toBe(2); }); + + /* + * **两次调用的用量都要交回去记账**(2026-08-29 终审 C1)。 + * `createModelGateway` 自己不做 `addConsumption` —— 不给 `onUsage`,配方里的 analyze + * 就是一条问模型不花钱的路,活动预算对它形同虚设。重问那一次照样烧真钱,一并记。 + */ + it('用量交回 onUsage:第一次成的记一次,重问了记两次', async () => { + const billed: Array<{ taskId: string; tokens: number }> = []; + const once = createAnalyzeGateway( + { + generate: () => + Promise.resolve({ text: '', object: { items: [] }, usage: { inputTokens: 30, outputTokens: 12, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }), + } as never, + (taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); }, + ); + await once.run(call); + expect(billed).toEqual([{ taskId: TASK, tokens: 42 }]); + + billed.length = 0; + let n = 0; + const twice = createAnalyzeGateway( + { + generate: () => { + n += 1; + return Promise.resolve( + n === 1 + ? { text: 'not json', usage: { inputTokens: 5, outputTokens: 5, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' } + : { text: '', object: { items: [] }, usage: { inputTokens: 7, outputTokens: 3, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }, + ); + }, + } as never, + (taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); }, + ); + await twice.run(call); + expect(billed.map((b) => b.tokens)).toEqual([10, 10]); + }); + + it('两次都不成形也照样记两笔 —— 失败的调用不是免费的', async () => { + const billed: number[] = []; + const gateway = createAnalyzeGateway( + { + generate: () => + Promise.resolve({ text: 'nope', usage: { inputTokens: 4, outputTokens: 6, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }), + } as never, + (_taskId, tokens) => { billed.push(tokens); return Promise.resolve(); }, + ); + await expect(gateway.run(call)).rejects.toThrow(/出参不成形/); + expect(billed).toEqual([10, 10]); + }); }); diff --git a/apps/api/src/app/wiring/analyze-gateway.ts b/apps/api/src/app/wiring/analyze-gateway.ts index aecd401..5b145ad 100644 --- a/apps/api/src/app/wiring/analyze-gateway.ts +++ b/apps/api/src/app/wiring/analyze-gateway.ts @@ -4,14 +4,28 @@ * 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。 * 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 —— * 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。 + * + * **用量必须交回去记账**(2026-08-29 终审 C1):`createModelGateway` 自己不做 + * `addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了, + * 只有这一条从前接的是**裸网关**:配方里 analyze 烧的 token 一个数都不计入活动预算, + * 时间轴上也没有模型 span(那要 `tracedGateway`,由装配层决定传谁进来)。 + * spec §5.2 写的是「走现有模型网关(四元绑定、凭证隔离、录制回放、**用量计入预算**)」—— + * 少了这一条,那句话就只是文档里的一厢情愿。 + * + * **两次都要记**:重问那一次照样烧真钱,漏记等于「失败的调用不要钱」。 */ import type { ModelGateway } from '@modules/llm'; import type { AnalyzeGateway } from '@modules/loop-engine'; +import type { TaskId } from '@contracts'; const RETRY_HINT = '上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。'; -export function createAnalyzeGateway(llm: Pick): AnalyzeGateway { +export function createAnalyzeGateway( + llm: Pick, + /** 用量回账 —— 与 `engines.ts` 里 agent 那两条路的 `onUsage` 同一个函数 */ + onUsage?: (taskId: TaskId, tokens: number) => Promise, +): AnalyzeGateway { return { async run(input) { const prompt = JSON.stringify(input.payload, null, 2); @@ -26,11 +40,18 @@ export function createAnalyzeGateway(llm: Pick): Analy taskId: input.taskId, label: input.label, }; + /* 记账**不吞异常**:记不上账 = 预算这道闸失效,宁可当场停下由配方留痕交人,也不要一单无声无息烧穿 */ + const bill = async (usage: { inputTokens: number; outputTokens: number }): Promise => { + if (onUsage === undefined) return; + await onUsage(input.taskId, usage.inputTokens + usage.outputTokens); + }; const first = await llm.generate(call); + await bill(first.usage); if (typeof first.object === 'object' && first.object !== null) { return { object: first.object as Record }; } const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` }); + await bill(second.usage); if (typeof second.object === 'object' && second.object !== null) { return { object: second.object as Record }; } diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index cd76b8a..6e99406 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -2290,7 +2290,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, tasks: taskContext, clock, - analyze: createAnalyzeGateway(deps.modelGateway), + /* + * **裸网关不行**(2026-08-29 终审 C1):从前这里传的是 `deps.modelGateway` —— + * 配方里 `analyze` 烧的 token 一个数都不计入活动预算(计量全靠调用方给的 `onUsage`), + * 时间轴上也不产生模型 span。agent 那两条路(:2194 / :2242)一直是 `tracedGateway` + `onAgentUsage`, + * 只有这一条漏了 —— 一个能问模型却不花钱的口子,预算这道闸对它就是不存在的。 + */ + analyze: createAnalyzeGateway(tracedGateway, onAgentUsage), }); /** diff --git a/docs/debt.md b/docs/debt.md index 0434226..dc12f50 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1329,7 +1329,10 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人 - **配方没有 `drop` 动词**(2026-08-29):撤行只能靠 `project` 的 `prune`(「这一笔投影出来的就是全部」)。 要撤一行而不重投整张表的场合,现在写不出来。等真出现第二个用例再加。 -- **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、计预算, +- **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、 + **用量已真的计入活动预算**(2026-08-29 终审 C1 修:装配层从裸网关改成 + `createAnalyzeGateway(tracedGateway, onAgentUsage)` —— 在那之前这半句是假的, + `createModelGateway` 自己不做 `addConsumption`,配方问模型是不花钱的), 但不算「被拒」——熔断看不见它。一份写坏的 `instruction` 会每拍烧一次而不触发熔断; 目前靠「auto 步发了卡就收拍」封住(analyze 那一步不发卡,`doneWhen` 不成立就交人), 真出现空转再补一道计数。