Commit ad264616 by luoqi

feat(助手): 每轮往 agent_invocations 落一行 —— 落「该调的工具调没调」,不落聊天记录

助手是这条生产线上唯一**一次都没留过痕**的一层:验收判据写着「该调的工具
调没调」,而线上没有任何地方记录模型实际调了什么,只能靠临时跑批。

表和写入器都是现成的(`agent_invocations` + `InvocationRecorderService`),
后台 `admin/ai-invocations` 也不写死 kind —— 落进去直接就能看。缺的只是接上。

■ 只存当轮, 不存上下文
  助手的 messages 里带着 propose_assignment 返回的整张确认单(几百人 ×
  十几个字段)。第 N 轮把前 N-1 轮全带上 ⇒ **落库量按平方涨**。
  实测口径:全量约 100 KB/行,只存当轮约 2.5 KB/行;按 1800 次/天算是
  65 GB/年 vs 1.6 GB/年 —— ️ 而测试机盘常年 90%+、PG 卷已占 75 G。
  ⇒ `slimInputSnapshot` 只取本轮用户原话 + 轮次 + 现场 id,
     不含历史、 不含任何工具返回值。同 agent-architecture「摘要 + 指针」:
    在源头就只给摘要, 不事后压缩(事后压缩要改历史,而改历史作废 KV 缓存)。

■ systemPrompt 一律不落
  主管那份 6.9 KB **每次一模一样**,逐行存等于把同一份东西抄一万遍。
  改用 `ASSISTANT_PROMPT_VERSION` 做锚 —— 🔴 改任意一层正文必须 bump,
  ️ 不 bump 这条审计链就断,而断了不会有任何报错。

■ 工具留痕装在一处
  在 tools 建好之后统一给**每个** execute 套计时包装(MCP 拉来的 + 本地那 7 个)。
   不在各自 execute 里写:漏一个,那个工具就永远不出现在验收数据里。
  落 output.toolCalls = [{name, ok, ms, args截200}], 不落工具返回值。

■ 落库绝不影响对话
  start/end 全程 try/catch 吞掉,只打日志;onFinish/onError 挂在 streamText 上。
  主管正等着一版方案, 不该因为审计写不进去而看不到结果。
  scope 缺失(内部入口)整条跳过 —— hostId/tenantId 是必填列。

■ 顺带补两个既有缺口
  1. 保留期清理任务(`InvocationRetentionService`,每天 04:00 沪)——
     schema 注释里写了一年「N 天后清 inputSnapshot 仅保留元数据」,任务一直不存在。
     清 inputSnapshot/prompt/systemPrompt/outputText 四样肥字段(占一行 95%+ 字节),
     留 token/成本/延迟/status/judge/userFeedback;失败行留 3 倍时长;
      不删行(成本与通过率曲线要长期可比,元数据行才 ~1 KB)。
     ️ 判据用 startedAt 不用 updatedAt —— 清理本身会刷新 updatedAt,
       用它当闸门的话清过的行永远追不上,每天全表重清一遍。
  2. 成本计算抽成共享纯函数 `ai/core/cost.ts` —— 它是**计价**逻辑,
     抄第二份必然漂(2026-08-13 栽过:换 qwen 旗舰后成本被低报约四倍)。

本地验证(不只是单测):
  · tsc 通过;jest 85 套 1325 例全过(新增 27 例);eslint 干净
  · 真 AppModule 起容器 → recorder 注入成功、retention 服务已注册
  · 保留期在真库上跑通:60 天前成功行已清且元数据完好、2 天前未动、
    失败行未动、第二次跑幂等、185 行真实数据零误伤
  · **真模型跑通一轮**(deepseek-v4-flash):
    tokens 1720/158/1878 · cached 896 · ¥0.001169 · latency 23.9s ·
    finishReason "stop" · inputSnapshot 82 B( 无历史)· systemPrompt 未落
    ️ 先用 MockLanguageModelV3 试过,usage 拿不到 —— 是 mock 喂不进去,
      不是代码问题;真模型一次跑通。

️ 遗留:代码里 ASSIGNMENT_SCENE 仍写「时效」,产品口径已改「时限」,未统一。
parent edbbe4f5
...@@ -43,6 +43,12 @@ export interface AppConfig { ...@@ -43,6 +43,12 @@ export interface AppConfig {
assistantVoice: string; assistantVoice: string;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可 /// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>; priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
/**
* `agent_invocations` 里**肥字段**(inputSnapshot / prompt / outputText)的保留天数。
* 到期后清成元数据行,⛔ 不删行(成本与通过率曲线要长期可比)。失败行留 3 倍时长。
* 0 = 不清理 —— ⚠️ 只在排查期临时这么配,助手是按对话轮数写库的,不清会涨得很快。
*/
invocationRetentionDays: number;
}; };
alert: { webhookUrl: string }; alert: { webhookUrl: string };
cors: { origins: string[] }; cors: { origins: string[] };
...@@ -84,6 +90,7 @@ export function loadConfig(): AppConfig { ...@@ -84,6 +90,7 @@ export function loadConfig(): AppConfig {
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180), requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '', assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
invocationRetentionDays: Number(process.env.AI_INVOCATION_RETENTION_DAYS ?? 30),
}, },
alert: { alert: {
webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '', webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '',
......
...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service'; ...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service'; import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service';
import { computeInputHash } from './core/hash.util'; import { computeInputHash } from './core/hash.util';
import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface'; import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface';
import { estimateCostYuan } from './core/cost';
/** /**
* 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端 * 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端
...@@ -460,8 +461,14 @@ export class AiCallRunnerService { ...@@ -460,8 +461,14 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0, cachedInputTokens: number = 0,
): number { ): number {
const priceTable = this.config.get('ai', { infer: true }).priceTable; const priceTable = this.config.get('ai', { infer: true }).priceTable;
const p = priceTable[modelId]; const { yuan, priceMissing } = estimateCostYuan(
if (!p) { priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
/** /**
* 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。 * 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。
* 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后, * 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后,
...@@ -474,13 +481,7 @@ export class AiCallRunnerService { ...@@ -474,13 +481,7 @@ export class AiCallRunnerService {
`补价:AI_PRICE_TABLE_JSON`, `补价:AI_PRICE_TABLE_JSON`,
); );
} }
const price = p ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 }; return yuan;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(cachedInputTokens, promptTokens);
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + completionTokens * price.out) / 1_000_000;
return Math.max(0, yuan);
} }
} }
......
...@@ -74,6 +74,8 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -74,6 +74,8 @@ import { PlanModule } from '../plan/plan.module';
], ],
exports: [ exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI) // 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
// 助手每轮往 agent_invocations 落一行,复用同一个 recorder(⛔ 别再抄一份写库逻辑)
InvocationRecorderService,
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入) WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
......
/**
* 成本估算 —— 纯函数,`AiCallRunner` 与助手共用**同一份**口径。
*
* ⚠️ 抽出来是因为它是**计价**逻辑:抄第二份必然会漂,而漂了之后报表上看不出任何异常
* (同一类 bug 2026-08-13 栽过一次:换 qwen 旗舰后成本被低报约四倍)。
*/
export interface ModelPrice {
/** ¥/M tokens —— 输入里命中 vendor prompt cache 的部分 */
inHit: number;
/** ¥/M tokens —— 输入里未命中的部分 */
inMiss: number;
/** ¥/M tokens —— 输出 */
out: number;
}
/** 价目表里查不到时的兜底价(与历史行为一致:按 deepseek-v4-pro 估)。 */
export const FALLBACK_PRICE: ModelPrice = { inHit: 0.5, inMiss: 3.6, out: 25 };
export interface CostResult {
yuan: number;
/** 价目表里没有这个模型 —— 调用方要吭声,⛔ 别静默 */
priceMissing: boolean;
}
export function estimateCostYuan(
priceTable: Record<string, ModelPrice>,
modelId: string,
promptTokens: number,
completionTokens: number,
cachedInputTokens = 0,
): CostResult {
const p = priceTable[modelId];
const price = p ?? priceTable['deepseek-v4-pro'] ?? FALLBACK_PRICE;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(Math.max(0, cachedInputTokens), Math.max(0, promptTokens));
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + Math.max(0, completionTokens) * price.out) /
1_000_000;
return { yuan: Math.max(0, yuan), priceMissing: !p };
}
...@@ -107,6 +107,16 @@ import { Permission } from '@pac/types'; ...@@ -107,6 +107,16 @@ import { Permission } from '@pac/types';
*/ */
/** /**
* 提示词版本 —— 落进 `agent_invocations.prompt_version`,是回放「当时那份提示词长什么样」的**唯一锚**。
*
* 🔴 **改动 ①〜⑤ 任意一层的正文,必须 bump 这个值**(连同当天日期)。
* ⚠️ 系统提示词**不逐行落库**:主管那份 6.9 KB 每次一模一样,逐行存等于把同一份东西
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-a';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
* *
* ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服, * ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服,
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
...@@ -10,7 +11,17 @@ import { ...@@ -10,7 +11,17 @@ import {
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { buildSystemPrompt } from './assistant-prompts'; import { InvocationRecorderService } from '../ai/core/invocation-recorder.service';
import { estimateCostYuan } from '../ai/core/cost';
import { buildSystemPrompt, ASSISTANT_PROMPT_VERSION } from './assistant-prompts';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
type ToolTraceEntry,
} from './assistant-invocation';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释) // ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
...@@ -177,6 +188,7 @@ export class AssistantService { ...@@ -177,6 +188,7 @@ export class AssistantService {
private readonly mcp: McpClientService, private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService, private readonly proposals: AssignmentProposalService,
private readonly config: ConfigService<AppConfig, true>, private readonly config: ConfigService<AppConfig, true>,
private readonly recorder: InvocationRecorderService,
) {} ) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部 // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
...@@ -913,6 +925,70 @@ export class AssistantService { ...@@ -913,6 +925,70 @@ export class AssistantService {
this.logger.log( this.logger.log(
`assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`, `assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`,
); );
/**
* ⭐ **每一轮往 `agent_invocations` 落一行** —— 落的是「该调的工具调没调」,
* ⛔ 不是聊天记录。在此之前这件事线上一次都没留过痕,只能靠临时跑批。
*
* ⚠️ 落库**绝不能影响这一轮对话** —— 全程 try/catch 吞掉,失败只打日志:
* 主管正等着一版方案,⛔ 不该因为审计写不进去而看不到结果。
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/
const toolTrace: ToolTraceEntry[] = [];
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for (const name of Object.keys(tools)) {
// AI SDK 的 ToolSet 是判别联合,execute 的入参类型在这一层无法窄化 ——
// 计时包装对参数**不做任何解释**,只透传,所以用 any 收口,边界只在这两行。
/* eslint-disable @typescript-eslint/no-explicit-any */
const t = tools[name] as { execute?: (...a: any[]) => any } | undefined;
const orig = t?.execute?.bind(t);
if (!t || !orig) continue;
t.execute = async (args: any, opts: any) => {
const t0 = Date.now();
try {
const r = await orig(args, opts);
toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
return r;
} catch (e) {
toolTrace.push({ name, ok: false, ms: Date.now() - t0, args: briefToolArgs(args) });
throw e;
}
};
/* eslint-enable @typescript-eslint/no-explicit-any */
}
const scope = input.scope;
const callKey = assistantCallKey(!!input.permissions?.includes(Permission.PLAN_DISPATCH));
const snapshot = slimInputSnapshot(input.messages, {
activeClinicId: input.activeClinicId,
activePatientId: input.activePatientId,
});
const startedAt = Date.now();
let invocationId: string | undefined;
if (scope) {
try {
invocationId = await this.recorder.start({
hostId: scope.hostId,
tenantId: scope.tenantId,
kind: 'assistant',
callKey,
promptVersion: ASSISTANT_PROMPT_VERSION,
modelProvider: resolved.provider,
modelName: resolved.modelId,
// 助手一轮 = 一个 run。⛔ 不跨轮串:每轮的上下文都不同,串起来没有可比性。
workflowRunId: randomUUID(),
inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
// ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨)
inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
// ⛔ systemPrompt / promptTemplate 一律不落:前者每次一模一样(靠 promptVersion 回查),
// 后者的内容已经在 inputSnapshot.userText 里了。
});
} catch (e) {
this.logger.warn(`invocation start 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
}
const { model } = resolved; const { model } = resolved;
return streamText({ return streamText({
model, model,
...@@ -953,9 +1029,78 @@ export class AssistantService { ...@@ -953,9 +1029,78 @@ export class AssistantService {
// 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。 // 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。
stopWhen: stepCountIs(MAX_TOOL_STEPS), stopWhen: stepCountIs(MAX_TOOL_STEPS),
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
onFinish: (ev) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, ev);
},
onError: ({ error }) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, {
errorMessage: error instanceof Error ? error.message : String(error),
});
},
}); });
} }
/**
* 一轮结束时回填 `agent_invocations` —— **失败只打日志,⛔ 绝不外抛**。
* 它挂在 streamText 的 onFinish/onError 上,抛出去会污染正在给主管吐字的那条流。
*/
private async finishInvocation(
invocationId: string | undefined,
modelId: string,
startedAt: number,
toolTrace: readonly ToolTraceEntry[],
ev: {
text?: string;
finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>;
usage?: Record<string, unknown>;
errorMessage?: string;
},
): Promise<void> {
if (!invocationId) return;
try {
const u = ev.totalUsage ?? ev.usage ?? {};
const num = (k: string): number => (typeof u[k] === 'number' ? (u[k] as number) : 0);
const promptTokens = num('inputTokens');
const completionTokens = num('outputTokens');
const cachedInputTokens = num('cachedInputTokens');
const { yuan, priceMissing } = estimateCostYuan(
this.config.get('ai', { infer: true }).priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
this.logger.warn(`价目表里没有 ${modelId} —— 本轮助手成本按兜底价估算,数字**不准**。`);
}
await this.recorder.end(invocationId, {
/**
* 🔴 **这一列才是落这行的理由** —— 「该调的工具调没调」是助手这层的验收判据。
* ⛔ 不放工具**返回值**:确认单那类肥载荷正是量级失控的源头。
*/
output: {
toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
toolCallCount: toolTrace.length,
finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue,
outputText: truncateOutputText(ev.text),
promptTokens,
completionTokens,
totalTokens: num('totalTokens') || promptTokens + completionTokens,
cachedInputTokens,
reasoningTokens: num('reasoningTokens'),
costYuan: yuan,
latencyMs: Date.now() - startedAt,
status: ev.errorMessage ? 'failed' : 'succeeded',
errorMessage: ev.errorMessage,
});
} catch (e) {
this.logger.warn(`invocation end 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
}
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */ /** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } { petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
const { model } = this.provider.resolve('deepseek'); const { model } = this.provider.resolve('deepseek');
......
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import type { AppConfig } from '../config/configuration';
/**
* InvocationRetentionService —— 定期把 `agent_invocations` 里**过期的肥字段**清掉,只留元数据。
*
* ═══ 为什么必须有 ═══════════════════════════════════════════════
* schema 上早就写着「成功调用 N 天后清 inputSnapshot 仅保留元数据」,但这个任务一直不存在。
* 助手接进来之后写入频率上一个量级(每轮对话一行),⚠️ 而测试机盘常年 90%+、
* PG 卷已占 75 G —— **不清就是把一个已知的紧张问题变成事故**。
*
* ═══ 清什么、留什么 ════════════════════════════════════════════
* 清:inputSnapshot(置为 {"pruned":true})· promptTemplate · systemPrompt · outputText
* —— 这四样占了一行 95% 以上的字节。
* 留:token / 成本 / 延迟 / status / callKey / promptVersion / modelName / judge / userFeedback
* —— 仪表盘、成本聚合、通过率统计要的全在这些列上,⛔ 一个都不动。
*
* ⚠️ **失败的留更久**(默认 3 倍):失败行的输入正是排查时唯一能看的东西。
* ⚠️ `inputSnapshot` 是 NOT NULL 列 ⇒ 只能覆盖成占位对象,⛔ 不能置 null。
* ⚠️ ⛔ 不删行:元数据行 ~1 KB,留着才有长期的成本 / 通过率曲线;肥字段清掉后
* 增长量已经可控(按 1800 次/天算约 0.65 GB/年)。
*/
@Injectable()
export class InvocationRetentionService {
private readonly logger = new Logger(InvocationRetentionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService<AppConfig, true>,
) {}
/** 每天 04:00(沪)—— 避开 DW 08:00 落库与随后的增量摄入。 */
@Cron(process.env.PAC_INVOCATION_RETENTION_CRON || '0 4 * * *', {
name: 'invocation-retention',
timeZone: 'Asia/Shanghai',
})
async prune(): Promise<void> {
const days = this.config.get('ai', { infer: true }).invocationRetentionDays;
if (days <= 0) {
this.logger.log('保留期配置为 0 —— 跳过清理(AI_INVOCATION_RETENTION_DAYS)');
return;
}
const okCount = await this.pruneOlderThan(days, ['succeeded', 'cached']);
// 失败的留 3 倍时长:排查时唯一能看的就是它的输入
const failCount = await this.pruneOlderThan(days * 3, ['failed']);
this.logger.log(
`invocation 清理完成:成功/缓存 ${okCount} 行(>${days} 天)· 失败 ${failCount} 行(>${days * 3} 天)`,
);
}
/**
* 清一批。⚠️ `updatedAt` 是 `@updatedAt` 列,这次 update 会把它刷新 ——
* 所以判据用 `startedAt`,⛔ 不能用 updatedAt(否则清过的行永远追不上闸门,每天重清一遍)。
*/
private async pruneOlderThan(days: number, statuses: string[]): Promise<number> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const res = await this.prisma.agentInvocation.updateMany({
where: {
startedAt: { lt: cutoff },
status: { in: statuses },
// ⭐ 幂等闸:已经清过的不再进来(否则每天全表扫一遍已清的行)
NOT: { inputSnapshot: { equals: { pruned: true } } },
},
data: {
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
},
});
return res.count;
}
}
...@@ -11,6 +11,7 @@ import { QueueProducer } from './queue-producer.service'; ...@@ -11,6 +11,7 @@ import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service'; import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler'; import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service'; import { DwLagMonitorService } from './dw-lag-monitor.service';
import { InvocationRetentionService } from './invocation-retention.service';
import { DailyHealthReportService } from './daily-health-report.service'; import { DailyHealthReportService } from './daily-health-report.service';
import { DailyHealthReportController } from './daily-health-report.controller'; import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
...@@ -71,6 +72,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor'; ...@@ -71,6 +72,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor';
SyncIncrementalSchedulerService, SyncIncrementalSchedulerService,
DwLagMonitorService, DwLagMonitorService,
DailyHealthReportService, DailyHealthReportService,
InvocationRetentionService,
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
......
import type { ModelMessage } from 'ai';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
import { estimateCostYuan, FALLBACK_PRICE } from '../src/modules/ai/core/cost';
/**
* 助手落 `agent_invocations` 的**纯函数层**。
*
* 这组测试守的是同一件事:**落库量不能随对话轮数涨**。
* 助手的 messages 里带着确认单那类肥载荷,一旦把上下文原样落进去,
* 第 N 轮就要写前 N-1 轮的全部内容 —— 按平方涨(实测口径 ~100 KB/行 vs ~2.5 KB/行)。
*/
/** 造一段带工具往返的多轮对话 —— 工具返回值刻意做得很肥。 */
function conversation(turns: number, fatBytes = 50_000): ModelMessage[] {
const out: ModelMessage[] = [];
for (let i = 1; i <= turns; i++) {
out.push({ role: 'user', content: `第 ${i} 轮:给我出一版` } as ModelMessage);
out.push({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(fatBytes) }],
} as unknown as ModelMessage);
}
return out;
}
describe('slimInputSnapshot —— 只存当轮', () => {
it('只取最后一条用户消息,⛔ 不带历史', () => {
const s = slimInputSnapshot(conversation(3));
expect(s.userText).toBe('第 3 轮:给我出一版');
expect(s.turnNo).toBe(3);
expect(s.priorTurns).toBe(2);
});
it('🔴 快照大小不随轮数增长 —— 这是这层存在的全部理由', () => {
const small = JSON.stringify(slimInputSnapshot(conversation(1))).length;
const large = JSON.stringify(slimInputSnapshot(conversation(20))).length;
// 只有 turnNo/priorTurns 的位数会变,差几个字符而已
expect(large - small).toBeLessThan(10);
// 而原始上下文本身是几十万字节
expect(JSON.stringify(conversation(20)).length).toBeGreaterThan(1_000_000);
});
it('⛔ 不含任何工具返回值 / 助手历史发言', () => {
const raw = JSON.stringify(slimInputSnapshot(conversation(5)));
expect(raw).not.toContain('xxxx');
});
it('用户原话截断到 2000 字', () => {
const s = slimInputSnapshot([
{ role: 'user', content: '甲'.repeat(5000) } as ModelMessage,
]);
expect(s.userText).toHaveLength(2000);
});
it('content 为 parts 数组时也能取到文本', () => {
const s = slimInputSnapshot([
{ role: 'user', content: [{ type: 'text', text: '换成正畸' }] } as unknown as ModelMessage,
]);
expect(s.userText).toBe('换成正畸');
});
it('没有用户消息时不炸', () => {
expect(slimInputSnapshot([])).toMatchObject({ turnNo: 0, userText: '', priorTurns: 0 });
});
it('现场上下文有才带,⛔ 不塞 undefined 键', () => {
const bare = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage]);
expect(Object.keys(bare)).not.toContain('activeClinicId');
const withCtx = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage], {
activeClinicId: 'c1',
activePatientId: 'p1',
});
expect(withCtx).toMatchObject({ activeClinicId: 'c1', activePatientId: 'p1' });
});
});
describe('briefToolArgs —— 参数截断', () => {
it('肥载荷截到 200 字', () => {
expect(briefToolArgs({ patients: 'p'.repeat(9999) })).toHaveLength(200);
});
it('不可序列化的对象不抛', () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(briefToolArgs(cyclic)).toBe('[unserializable]');
});
it('undefined 归一成 {}', () => {
expect(briefToolArgs(undefined)).toBe('{}');
});
});
describe('truncateOutputText', () => {
it('截到 8000', () => {
expect(truncateOutputText('乙'.repeat(20_000))).toHaveLength(8000);
});
it('空值原样返回 undefined', () => {
expect(truncateOutputText(undefined)).toBeUndefined();
expect(truncateOutputText('')).toBeUndefined();
});
});
describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment');
expect(assistantCallKey(false)).toBe('assistant_execute');
});
});
describe('inputHash', () => {
it('同输入同 hash,改一个字就变', () => {
const a = slimInputSnapshot([{ role: 'user', content: '发 200 人' } as ModelMessage]);
const b = slimInputSnapshot([{ role: 'user', content: '发 300 人' } as ModelMessage]);
const k = 'assistant_assignment';
const v = 'assistant@test';
expect(assistantInputHash(k, v, a)).toBe(assistantInputHash(k, v, a));
expect(assistantInputHash(k, v, a)).not.toBe(assistantInputHash(k, v, b));
});
it('提示词版本变了,hash 必须跟着变', () => {
const s = slimInputSnapshot([{ role: 'user', content: 'x' } as ModelMessage]);
expect(assistantInputHash('k', 'v1', s)).not.toBe(assistantInputHash('k', 'v2', s));
});
});
describe('estimateCostYuan —— 与 AiCallRunner 共用同一份口径', () => {
const table = { m1: { inHit: 1, inMiss: 10, out: 100 } };
it('命中缓存的部分按 inHit 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 1_000_000);
expect(yuan).toBeCloseTo(1, 6);
});
it('未命中的部分按 inMiss 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 0);
expect(yuan).toBeCloseTo(10, 6);
});
it('输出按 out 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 0, 1_000_000, 0);
expect(yuan).toBeCloseTo(100, 6);
});
it('🔴 价目表里没有就报 priceMissing —— ⛔ 不许静默低报', () => {
const { priceMissing } = estimateCostYuan(table, '未知模型', 100, 100);
expect(priceMissing).toBe(true);
});
it('cached > prompt 时 clamp,不出负数', () => {
const { yuan } = estimateCostYuan(table, 'm1', 100, 0, 999_999);
expect(yuan).toBeGreaterThanOrEqual(0);
expect(yuan).toBeCloseTo((100 * 1) / 1_000_000, 9);
});
it('表全空时走兜底价', () => {
const { yuan, priceMissing } = estimateCostYuan({}, 'x', 1_000_000, 0, 0);
expect(priceMissing).toBe(true);
expect(yuan).toBeCloseTo(FALLBACK_PRICE.inMiss, 6);
});
});
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { InvocationRetentionService } from '../src/queues/invocation-retention.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* `agent_invocations` 的保留期清理。
*
* 🔴 这段直接关系到**磁盘**:助手接进来之后按对话轮数写库,
* 而测试机盘常年 90%+、PG 卷已占 75 G。不清就是把一个已知的紧张问题变成事故。
*
* 这里锁住四件事:清哪些状态 · 失败留更久 · 清哪几列 · 幂等闸在不在。
*/
type Where = Record<string, unknown>;
type Call = { where: Where; data: Record<string, unknown> };
function build(retentionDays: number) {
const calls: Call[] = [];
const prisma = {
agentInvocation: {
updateMany: jest.fn(async (args: Call) => {
calls.push(args);
return { count: 1 };
}),
},
};
return { calls, prisma };
}
async function make(retentionDays: number) {
const { calls, prisma } = build(retentionDays);
const mod = await Test.createTestingModule({
providers: [
InvocationRetentionService,
{ provide: PrismaService, useValue: prisma },
{
provide: ConfigService,
useValue: { get: () => ({ invocationRetentionDays: retentionDays }) },
},
],
}).compile();
return { svc: mod.get(InvocationRetentionService), calls, prisma };
}
describe('InvocationRetentionService', () => {
it('分两批清:成功/缓存一批,失败一批', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls).toHaveLength(2);
expect(calls[0].where.status).toEqual({ in: ['succeeded', 'cached'] });
expect(calls[1].where.status).toEqual({ in: ['failed'] });
});
it('🔴 失败的留 3 倍时长 —— 排查时唯一能看的就是它的输入', async () => {
const { svc, calls } = await make(30);
await svc.prune();
const okCutoff = (calls[0].where.startedAt as { lt: Date }).lt.getTime();
const failCutoff = (calls[1].where.startedAt as { lt: Date }).lt.getTime();
const days = (ms: number) => Math.round((Date.now() - ms) / 86_400_000);
expect(days(okCutoff)).toBe(30);
expect(days(failCutoff)).toBe(90);
});
it('只清肥字段,⛔ 不碰 token / 成本 / 状态这些元数据列', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].data).toEqual({
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
});
for (const k of ['costYuan', 'totalTokens', 'status', 'callKey', 'promptVersion', 'judgeScore', 'userFeedback']) {
expect(Object.keys(calls[0].data)).not.toContain(k);
}
});
it('🔴 带幂等闸 —— 已清过的不再进来,否则每天全表重扫', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where.NOT).toEqual({ inputSnapshot: { equals: { pruned: true } } });
});
it('⚠️ 判据用 startedAt,⛔ 不能用 updatedAt(清理本身会刷新它)', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where).toHaveProperty('startedAt');
expect(calls[0].where).not.toHaveProperty('updatedAt');
});
it('配 0 = 关掉清理,一次都不写库', async () => {
const { svc, prisma } = await make(0);
await svc.prune();
expect(prisma.agentInvocation.updateMany).not.toHaveBeenCalled();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment