ai-call-logger.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * AI 调用日志 —— 记录每次大模型 / TTS API 请求,方便对账
  3. *
  4. * 写入策略:fire-and-forget,不阻塞业务请求
  5. */
  6. import { prisma } from '../models';
  7. import { TOKEN_COST_CONFIG, PRICING_COEFFICIENT } from '../modules/subscription/subscription.service';
  8. import { getLlmContext } from './llm-context';
  9. export interface LogCallParams {
  10. callType: string; // llm_chat | llm_tools | tts_synthesize | tts_create | tts_poll
  11. provider: string; // minimax | bailian | volcengine
  12. model: string; // MiniMax-M2.7 | cosyvoice-v3-flash | qwen3-tts-instruct-flash
  13. textLen?: number; // 输入文本长度(字符)
  14. prompt?: string; // 提示词内容(LLM only)
  15. inputTokens?: number; // 实际输入 Token 数
  16. outputTokens?: number;// 实际输出 Token 数(LLM)或 文本长度(TTS)
  17. duration?: number; // 耗时(ms)
  18. success?: boolean;
  19. errorMsg?: string;
  20. userId?: number;
  21. bookId?: number;
  22. chapterId?: number;
  23. }
  24. /**
  25. * 根据 callType + model + token 数估算成本
  26. * LLM: 按百万token单价计算
  27. * TTS: 按万字符单价计算(cosyvoice ¥1/万字符)
  28. */
  29. function estimateCost(params: LogCallParams): number {
  30. const inputTokens = params.inputTokens || 0;
  31. const outputTokens = params.outputTokens || 0;
  32. const textLen = params.textLen || 0;
  33. // LLM 调用:按 token 计费
  34. if (params.callType.startsWith('llm_')) {
  35. const modelKey = params.model.includes('qwen') ? 'qwen_plus' :
  36. params.model.includes('deepseek') ? 'deepseek_v3' : 'qwen_plus';
  37. const modelConfig = TOKEN_COST_CONFIG.models[modelKey] || TOKEN_COST_CONFIG.models.qwen_plus;
  38. const inputCost = (inputTokens / 1_000_000) * modelConfig.inputCostPerMToken;
  39. const outputCost = (outputTokens / 1_000_000) * modelConfig.outputCostPerMToken;
  40. return Math.round((inputCost + outputCost) * 10000) / 10000;
  41. }
  42. // TTS 实际合成调用:按字符数计费(cosyvoice ¥1/万字符)
  43. // tts_task_completed/failed 是任务汇总日志,不计费
  44. if (params.callType === 'tts_synthesize' || params.callType === 'tts_create') {
  45. const chars = outputTokens || textLen;
  46. return Math.round((chars / 10_000) * 1.0 * 10000) / 10000;
  47. }
  48. return 0;
  49. }
  50. /**
  51. * 从 LangChain AIMessage 响应中提取 token 用量
  52. * 支持 OpenAI-compatible API(usage.prompt_tokens / completion_tokens)
  53. */
  54. export function extractTokenUsage(response: any): { inputTokens: number; outputTokens: number } | null {
  55. try {
  56. // LangChain AIMessage 包含 response_metadata
  57. const metadata = response?.response_metadata || response?.lc_kwargs?.response_metadata || {};
  58. const usage = metadata?.tokenUsage || metadata?.usage || metadata?.token_usage || {};
  59. // 尝试多种可能的字段名
  60. const promptTokens = usage?.prompt_tokens || usage?.promptTokens || usage?.input_tokens || usage?.inputTokens;
  61. const completionTokens = usage?.completion_tokens || usage?.completionTokens || usage?.output_tokens || usage?.outputTokens || usage?.total_tokens;
  62. if (promptTokens || completionTokens) {
  63. return {
  64. inputTokens: promptTokens || 0,
  65. outputTokens: completionTokens || 0,
  66. };
  67. }
  68. } catch {}
  69. return null;
  70. }
  71. /**
  72. * 估算 Token 数(当 API 未返回实际用量时)
  73. * 中文:1 字 ≈ 1.5 token
  74. */
  75. function estimateTokens(text: string): number {
  76. return Math.ceil(text.length * 1.5);
  77. }
  78. /**
  79. * 记录一次 AI 调用(异步写入,不阻塞)
  80. */
  81. export function logAiCall(params: LogCallParams): void {
  82. // 从异步上下文自动填充 userId / bookId / chapterId(如果调用方未显式传入)
  83. const ctx = getLlmContext();
  84. if (ctx) {
  85. if (params.userId === undefined && ctx.userId !== undefined) params.userId = ctx.userId;
  86. if (params.bookId === undefined && ctx.bookId !== undefined) params.bookId = ctx.bookId;
  87. if (params.chapterId === undefined && ctx.chapterId !== undefined) params.chapterId = ctx.chapterId;
  88. }
  89. const estimatedCost = estimateCost(params);
  90. console.log(
  91. `[AiCallLog] ${params.callType} | ${params.provider} | ${params.model} | ` +
  92. `${params.success ? 'OK' : 'FAIL'} | ` +
  93. `in:${params.inputTokens || 0}tk out:${params.outputTokens || 0}tk ` +
  94. `text:${params.textLen || 0}字 | cost:¥${estimatedCost}`
  95. );
  96. prisma.aiCallLog.create({
  97. data: {
  98. callType: params.callType,
  99. provider: params.provider,
  100. model: params.model,
  101. userId: params.userId ?? null,
  102. bookId: params.bookId ?? null,
  103. chapterId: params.chapterId ?? null,
  104. textLen: params.textLen ?? 0,
  105. prompt: params.prompt?.substring(0, 5000) ?? null,
  106. inputTokens: params.inputTokens ?? 0,
  107. outputTokens: params.outputTokens ?? 0,
  108. estimatedCost,
  109. duration: params.duration ?? 0,
  110. success: params.success ?? true,
  111. errorMsg: params.errorMsg ?? null,
  112. },
  113. }).then(() => {
  114. // Success
  115. }).catch(err => {
  116. console.error('[AiCallLog] ❌ 写入数据库失败:', err.message);
  117. });
  118. }
  119. /**
  120. * 包装异步函数,自动计时 + 记录日志 + 提取 token 用量
  121. *
  122. * 用法:
  123. * const result = await withAiLog(
  124. * () => llm.invoke(messages),
  125. * { callType: 'llm_chat', provider: 'bailian', model: 'qwen3.6-plus', textLen: 500, prompt }
  126. * );
  127. */
  128. export async function withAiLog<T>(
  129. fn: () => Promise<T>,
  130. params: LogCallParams,
  131. ): Promise<T> {
  132. const t0 = Date.now();
  133. try {
  134. const result = await fn();
  135. // 尝试从响应中提取 token 用量
  136. const tokenUsage = extractTokenUsage(result);
  137. const logParams: LogCallParams = {
  138. ...params,
  139. duration: Date.now() - t0,
  140. };
  141. if (tokenUsage) {
  142. // 有实际 token 数据
  143. logParams.inputTokens = tokenUsage.inputTokens;
  144. logParams.outputTokens = tokenUsage.outputTokens;
  145. } else if (params.callType.startsWith('llm_')) {
  146. // LLM 调用但无 token 数据:估算
  147. const promptText = params.prompt || '';
  148. const outputText = typeof result === 'string' ? result :
  149. (result as any)?.content && typeof (result as any).content === 'string' ? (result as any).content : '';
  150. logParams.inputTokens = estimateTokens(promptText);
  151. logParams.outputTokens = estimateTokens(outputText);
  152. } else if (params.callType.startsWith('tts_')) {
  153. // TTS 调用:outputTokens = 文本长度(用于成本计算)
  154. logParams.outputTokens = params.textLen || 0;
  155. }
  156. logAiCall(logParams);
  157. return result;
  158. } catch (err: any) {
  159. logAiCall({ ...params, duration: Date.now() - t0, success: false, errorMsg: err.message });
  160. throw err;
  161. }
  162. }
  163. /**
  164. * 无包装直接记录(用于不需要包裹异步函数的情况)
  165. */
  166. export function logTtsCall(params: LogCallParams): void {
  167. // TTS 成本基于文本长度
  168. const logParams: LogCallParams = {
  169. ...params,
  170. outputTokens: params.textLen || 0, // TTS 用 outputTokens 存文本长度
  171. };
  172. logAiCall(logParams);
  173. }