| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- /**
- * AI 调用日志 —— 记录每次大模型 / TTS API 请求,方便对账
- *
- * 写入策略:fire-and-forget,不阻塞业务请求
- */
- import { prisma } from '../models';
- import { TOKEN_COST_CONFIG, PRICING_COEFFICIENT } from '../modules/subscription/subscription.service';
- import { getLlmContext } from './llm-context';
- export interface LogCallParams {
- callType: string; // llm_chat | llm_tools | tts_synthesize | tts_create | tts_poll
- provider: string; // minimax | bailian | volcengine
- model: string; // MiniMax-M2.7 | cosyvoice-v3-flash | qwen3-tts-instruct-flash
- textLen?: number; // 输入文本长度(字符)
- prompt?: string; // 提示词内容(LLM only)
- inputTokens?: number; // 实际输入 Token 数
- outputTokens?: number;// 实际输出 Token 数(LLM)或 文本长度(TTS)
- duration?: number; // 耗时(ms)
- success?: boolean;
- errorMsg?: string;
- userId?: number;
- bookId?: number;
- chapterId?: number;
- }
- /**
- * 根据 callType + model + token 数估算成本
- * LLM: 按百万token单价计算
- * TTS: 按万字符单价计算(cosyvoice ¥1/万字符)
- */
- function estimateCost(params: LogCallParams): number {
- const inputTokens = params.inputTokens || 0;
- const outputTokens = params.outputTokens || 0;
- const textLen = params.textLen || 0;
- // LLM 调用:按 token 计费
- if (params.callType.startsWith('llm_')) {
- const modelKey = params.model.includes('qwen') ? 'qwen_plus' :
- params.model.includes('deepseek') ? 'deepseek_v3' : 'qwen_plus';
- const modelConfig = TOKEN_COST_CONFIG.models[modelKey] || TOKEN_COST_CONFIG.models.qwen_plus;
- const inputCost = (inputTokens / 1_000_000) * modelConfig.inputCostPerMToken;
- const outputCost = (outputTokens / 1_000_000) * modelConfig.outputCostPerMToken;
- return Math.round((inputCost + outputCost) * 10000) / 10000;
- }
- // TTS 实际合成调用:按字符数计费(cosyvoice ¥1/万字符)
- // tts_task_completed/failed 是任务汇总日志,不计费
- if (params.callType === 'tts_synthesize' || params.callType === 'tts_create') {
- const chars = outputTokens || textLen;
- return Math.round((chars / 10_000) * 1.0 * 10000) / 10000;
- }
- return 0;
- }
- /**
- * 从 LangChain AIMessage 响应中提取 token 用量
- * 支持 OpenAI-compatible API(usage.prompt_tokens / completion_tokens)
- */
- export function extractTokenUsage(response: any): { inputTokens: number; outputTokens: number } | null {
- try {
- // LangChain AIMessage 包含 response_metadata
- const metadata = response?.response_metadata || response?.lc_kwargs?.response_metadata || {};
- const usage = metadata?.tokenUsage || metadata?.usage || metadata?.token_usage || {};
- // 尝试多种可能的字段名
- const promptTokens = usage?.prompt_tokens || usage?.promptTokens || usage?.input_tokens || usage?.inputTokens;
- const completionTokens = usage?.completion_tokens || usage?.completionTokens || usage?.output_tokens || usage?.outputTokens || usage?.total_tokens;
- if (promptTokens || completionTokens) {
- return {
- inputTokens: promptTokens || 0,
- outputTokens: completionTokens || 0,
- };
- }
- } catch {}
- return null;
- }
- /**
- * 估算 Token 数(当 API 未返回实际用量时)
- * 中文:1 字 ≈ 1.5 token
- */
- function estimateTokens(text: string): number {
- return Math.ceil(text.length * 1.5);
- }
- /**
- * 记录一次 AI 调用(异步写入,不阻塞)
- */
- export function logAiCall(params: LogCallParams): void {
- // 从异步上下文自动填充 userId / bookId / chapterId(如果调用方未显式传入)
- const ctx = getLlmContext();
- if (ctx) {
- if (params.userId === undefined && ctx.userId !== undefined) params.userId = ctx.userId;
- if (params.bookId === undefined && ctx.bookId !== undefined) params.bookId = ctx.bookId;
- if (params.chapterId === undefined && ctx.chapterId !== undefined) params.chapterId = ctx.chapterId;
- }
- const estimatedCost = estimateCost(params);
- console.log(
- `[AiCallLog] ${params.callType} | ${params.provider} | ${params.model} | ` +
- `${params.success ? 'OK' : 'FAIL'} | ` +
- `in:${params.inputTokens || 0}tk out:${params.outputTokens || 0}tk ` +
- `text:${params.textLen || 0}字 | cost:¥${estimatedCost}`
- );
- prisma.aiCallLog.create({
- data: {
- callType: params.callType,
- provider: params.provider,
- model: params.model,
- userId: params.userId ?? null,
- bookId: params.bookId ?? null,
- chapterId: params.chapterId ?? null,
- textLen: params.textLen ?? 0,
- prompt: params.prompt?.substring(0, 5000) ?? null,
- inputTokens: params.inputTokens ?? 0,
- outputTokens: params.outputTokens ?? 0,
- estimatedCost,
- duration: params.duration ?? 0,
- success: params.success ?? true,
- errorMsg: params.errorMsg ?? null,
- },
- }).then(() => {
- // Success
- }).catch(err => {
- console.error('[AiCallLog] ❌ 写入数据库失败:', err.message);
- });
- }
- /**
- * 包装异步函数,自动计时 + 记录日志 + 提取 token 用量
- *
- * 用法:
- * const result = await withAiLog(
- * () => llm.invoke(messages),
- * { callType: 'llm_chat', provider: 'bailian', model: 'qwen3.6-plus', textLen: 500, prompt }
- * );
- */
- export async function withAiLog<T>(
- fn: () => Promise<T>,
- params: LogCallParams,
- ): Promise<T> {
- const t0 = Date.now();
- try {
- const result = await fn();
- // 尝试从响应中提取 token 用量
- const tokenUsage = extractTokenUsage(result);
- const logParams: LogCallParams = {
- ...params,
- duration: Date.now() - t0,
- };
- if (tokenUsage) {
- // 有实际 token 数据
- logParams.inputTokens = tokenUsage.inputTokens;
- logParams.outputTokens = tokenUsage.outputTokens;
- } else if (params.callType.startsWith('llm_')) {
- // LLM 调用但无 token 数据:估算
- const promptText = params.prompt || '';
- const outputText = typeof result === 'string' ? result :
- (result as any)?.content && typeof (result as any).content === 'string' ? (result as any).content : '';
- logParams.inputTokens = estimateTokens(promptText);
- logParams.outputTokens = estimateTokens(outputText);
- } else if (params.callType.startsWith('tts_')) {
- // TTS 调用:outputTokens = 文本长度(用于成本计算)
- logParams.outputTokens = params.textLen || 0;
- }
- logAiCall(logParams);
- return result;
- } catch (err: any) {
- logAiCall({ ...params, duration: Date.now() - t0, success: false, errorMsg: err.message });
- throw err;
- }
- }
- /**
- * 无包装直接记录(用于不需要包裹异步函数的情况)
- */
- export function logTtsCall(params: LogCallParams): void {
- // TTS 成本基于文本长度
- const logParams: LogCallParams = {
- ...params,
- outputTokens: params.textLen || 0, // TTS 用 outputTokens 存文本长度
- };
- logAiCall(logParams);
- }
|