fix(app): 配方的 analyze 走 traced 网关并回账用量 —— 一条问模型不花钱的路

装配层从前传的是裸 `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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:18:10 +08:00
co-authored by Claude Opus 5
parent bd26756da9
commit fcf1822233
4 changed files with 82 additions and 3 deletions
@@ -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]);
});
});
+22 -1
View File
@@ -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<ModelGateway, 'generate'>): AnalyzeGateway {
export function createAnalyzeGateway(
llm: Pick<ModelGateway, 'generate'>,
/** 用量回账 —— 与 `engines.ts` 里 agent 那两条路的 `onUsage` 同一个函数 */
onUsage?: (taskId: TaskId, tokens: number) => Promise<void>,
): AnalyzeGateway {
return {
async run(input) {
const prompt = JSON.stringify(input.payload, null, 2);
@@ -26,11 +40,18 @@ export function createAnalyzeGateway(llm: Pick<ModelGateway, 'generate'>): Analy
taskId: input.taskId,
label: input.label,
};
/* 记账**不吞异常**:记不上账 = 预算这道闸失效,宁可当场停下由配方留痕交人,也不要一单无声无息烧穿 */
const bill = async (usage: { inputTokens: number; outputTokens: number }): Promise<void> => {
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<string, unknown> };
}
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<string, unknown> };
}
+7 -1
View File
@@ -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),
});
/**