/** * 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( fn: () => Promise, params: LogCallParams, ): Promise { 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); }