模型调用日志

要点

  • 模型调用日志记录每次大模型 API 请求的完整上下文——请求参数、响应结果、耗时、Token 消耗、费用
  • 普通 console.log 只关心「程序是否正常」,模型调用日志还要关心「输入输出是什么」和「花了多少钱」
  • 日志字段分三类:请求上下文、响应结果、运行时指标
  • KV 适合简单日志存储,D1 适合需要 SQL 查询和统计的场景
  • 日志写入用 waitUntil 异步执行,不阻塞主请求
  • Prompt 和 Completion 可能包含用户隐私,写入前需要脱敏

1. 从一个具体的问题开始

前面实现了 AI API 网关,有鉴权、限流、流式代理。上线后会遇到一个问题:

过去 7 天,应用花了多少钱在 GPT-4o 上?

如果只靠 console.log 打了几行请求信息,这个问题答不上来。具体场景:

  • 某用户反馈回答质量差,想回看他发了什么 Prompt、模型返回了什么
  • Token 消耗突然翻倍,想知道是某个用户用量异常还是整体增长
  • 产品团队想知道各功能的日均调用量和平均延迟

这些需求都指向同一件事:需要一套专门的模型调用日志系统

2. 和普通应用日志的区别

先看一个典型的 console.log

// src/routes/chat.ts
console.log('Calling OpenAI', { model: 'gpt-4o', userId: 'user-123', status: response.status })

这行日志告诉你「有一次请求」,但回答不了:花了多少钱?用户发了什么?模型返回了什么?等了多久?

两类日志的关注点不同:

维度普通应用日志模型调用日志
关注点程序行为、错误每次调用的输入输出和成本
典型字段level、message、errormodel、prompt、completion、tokens、cost
查询需求按时间 + 关键字搜索按用户、模型、Token 范围等多维筛选
敏感信息通常不敏感Prompt/Completion 可能包含用户隐私

3. 日志字段设计

一条模型调用日志覆盖三类信息。

请求上下文

描述「谁在什么时候发了什么请求」:

// src/logging/types.ts
type ModelLogRequest = {
  requestId: string        // 唯一标识,用 crypto.randomUUID() 生成
  timestamp: string        // ISO 8601 格式,人眼可读
  userId: string
  model: string            // 例如 'gpt-4o'、'claude-sonnet-4-20250514'
  provider: string         // 例如 'openai'、'anthropic'
  messages: Array<{ role: string; content: string }>
  stream: boolean
}

响应结果

描述「模型返回了什么」:

// src/logging/types.ts
type ModelLogResponse = {
  completion: string       // 模型返回的内容
  statusCode: number
  errorMessage?: string
  providerRequestId?: string  // 供应商的请求 ID
  finishReason?: string       // stop / length / content_filter
}

运行时指标

描述「这次调用花了多少资源」:

// src/logging/types.ts
type ModelLogMetrics = {
  promptTokens: number
  completionTokens: number
  totalTokens: number
  latencyMs: number
  cost: number             // 美元,预先算好
  retryCount: number
}

合成一条完整日志:

// src/logging/types.ts
type ModelInvocationLog = {
  version: 1
  type: 'model_invocation'
} & ModelLogRequest & ModelLogResponse & ModelLogMetrics

一条实际数据示例(省略了部分字段):

// log.json
{
  "version": 1, "type": "model_invocation",
  "requestId": "req_a1b2c3d4", "timestamp": "2026-06-21T08:30:00.000Z",
  "userId": "user-123", "model": "gpt-4o", "provider": "openai",
  "messages": [{ "role": "user", "content": "帮我写一个快速排序" }],
  "completion": "好的,这是一个快速排序的实现...",
  "statusCode": 200, "finishReason": "stop",
  "promptTokens": 25, "completionTokens": 380, "totalTokens": 405,
  "latencyMs": 3200, "cost": 0.006075, "retryCount": 0
}

version 字段看起来多余,但字段增减时你会感谢自己留了这个。

4. 费用计算

cost 不是模型返回的,需要自己算。基本逻辑:输入 Token × 输入单价 + 输出 Token × 输出单价。

// src/logging/cost.ts
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
  'gpt-4o': { input: 0.0025, output: 0.01 },
  'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
  'claude-sonnet-4-20250514': { input: 0.003, output: 0.015 },
}
 
function calculateCost(model: string, promptTokens: number, completionTokens: number): number {
  const pricing = MODEL_PRICING[model]
  if (!pricing) return 0
  const cost = (promptTokens / 1000) * pricing.input + (completionTokens / 1000) * pricing.output
  return Number(cost.toFixed(6))
}

价格会随供应商调整变化。生产项目中可以把价格表放在数据库或环境变量里,方便更新。

5. 日志存储方案

KV 存储

写入快、无需建表,但只能按 key 精确查找或前缀扫描:

// src/logging/kv-logger.ts
async function saveLogToKV(kv: KVNamespace, log: ModelInvocationLog): Promise<void> {
  const key = `log:${log.timestamp.slice(0, 10)}:${log.requestId}`
  await kv.put(key, JSON.stringify(log), { expirationTtl: 90 * 24 * 60 * 60 })
}

D1 数据库

支持 SQL 查询和聚合统计,适合需要多维筛选的场景:

-- migrations/0001_create_model_logs.sql
CREATE TABLE model_invocation_logs (
  id TEXT PRIMARY KEY, request_id TEXT NOT NULL, timestamp TEXT NOT NULL,
  user_id TEXT NOT NULL, model TEXT NOT NULL, provider TEXT NOT NULL,
  messages TEXT, completion TEXT, status_code INTEGER, finish_reason TEXT,
  prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0,
  total_tokens INTEGER DEFAULT 0, latency_ms INTEGER DEFAULT 0,
  cost REAL DEFAULT 0, retry_count INTEGER DEFAULT 0, error_message TEXT
);
CREATE INDEX idx_logs_user_id ON model_invocation_logs(user_id);
CREATE INDEX idx_logs_timestamp ON model_invocation_logs(timestamp);
CREATE INDEX idx_logs_model ON model_invocation_logs(model);
// src/logging/d1-logger.ts
async function saveLogToD1(db: D1Database, log: ModelInvocationLog): Promise<void> {
  await db.prepare(`
    INSERT INTO model_invocation_logs
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).bind(
    log.requestId, log.requestId, log.timestamp, log.userId,
    log.model, log.provider, JSON.stringify(log.messages), log.completion,
    log.statusCode, log.finishReason ?? null,
    log.promptTokens, log.completionTokens, log.totalTokens,
    log.latencyMs, log.cost, log.retryCount, log.errorMessage ?? null,
  ).run()
}

怎么选

  • 项目早期日志量不大 → 单独用 D1 就够了
  • 需要快速查最近 N 条 → KV 做 24 小时缓存
  • 团队已有 Logtail / Axiom 等平台 → 推送过去,自带查询和仪表盘

三种方案不互斥,常见组合:D1 存结构化日志 + 外部服务存完整日志(含 Prompt/Completion 全文)。

6. 接入模型调用流程

在调用模型 API 的地方加日志收集层。成功和失败都要记录,写入不阻塞响应。

// src/routes/chat-with-logging.ts
chatApp.post('/api/chat', async (c) => {
  const { messages, model = 'gpt-4o' } = await c.req.json()
  const userId = c.get('user')?.id ?? 'anonymous'
  const requestId = crypto.randomUUID()
  const startTime = Date.now()
 
  const log: ModelInvocationLog = {
    version: 1, type: 'model_invocation',
    requestId, timestamp: new Date().toISOString(),
    userId, model, provider: 'openai', messages, stream: false,
    completion: '', statusCode: 0, finishReason: '',
    promptTokens: 0, completionTokens: 0, totalTokens: 0,
    latencyMs: 0, cost: 0, retryCount: 0,
  }
 
  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ model, messages }),
    })
    const data = await response.json()
 
    log.completion = data.choices?.[0]?.message?.content ?? ''
    log.statusCode = response.status
    log.finishReason = data.choices?.[0]?.finish_reason ?? ''
    log.promptTokens = data.usage?.prompt_tokens ?? 0
    log.completionTokens = data.usage?.completion_tokens ?? 0
    log.totalTokens = data.usage?.total_tokens ?? 0
    log.latencyMs = Date.now() - startTime
    log.cost = calculateCost(model, log.promptTokens, log.completionTokens)
    c.executionCtx.waitUntil(saveLogToD1(c.env.DB, log))
    return c.json(data)
  } catch (error) {
    log.statusCode = 500
    log.errorMessage = error instanceof Error ? error.message : String(error)
    log.latencyMs = Date.now() - startTime
    c.executionCtx.waitUntil(saveLogToD1(c.env.DB, log))
    return c.json({ error: 'Model service error' }, 500)
  }
})

waitUntil 让日志写入在响应返回之后执行,即使写入失败也不影响用户请求。日志系统是辅助功能,不应成为主链路的单点故障。

7. 日志查询接口

D1 的好处是可以直接用 SQL 做多维筛选:

// src/routes/log-query.ts
logQueryApp.get('/api/logs', async (c) => {
  const db = c.env.DB
  const userId = c.req.query('userId')
  const model = c.req.query('model')
  const startTime = c.req.query('startTime')
  const endTime = c.req.query('endTime')
  const page = Number(c.req.query('page') ?? '1')
  const pageSize = Math.min(Number(c.req.query('pageSize') ?? '20'), 100)
 
  const conditions: string[] = []
  const params: Array<string | number> = []
 
  if (userId) { conditions.push('user_id = ?'); params.push(userId) }
  if (model) { conditions.push('model = ?'); params.push(model) }
  if (startTime) { conditions.push('timestamp >= ?'); params.push(startTime) }
  if (endTime) { conditions.push('timestamp <= ?'); params.push(endTime) }
 
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
 
  const { results } = await db
    .prepare(`
      SELECT id, request_id, timestamp, user_id, model, status_code,
             prompt_tokens, completion_tokens, total_tokens, latency_ms, cost
      FROM model_invocation_logs ${where}
      ORDER BY timestamp DESC LIMIT ? OFFSET ?
    `)
    .bind(...params, pageSize, (page - 1) * pageSize)
    .all()
 
  return c.json({ data: results })
})

列表接口不返回 messagescompletion——一条带完整内容的日志可能有几十 KB,翻页时带宽消耗大。只在详情接口返回:

// src/routes/log-query.ts
logQueryApp.get('/api/logs/:id', async (c) => {
  const log = await c.env.DB
    .prepare('SELECT * FROM model_invocation_logs WHERE id = ?')
    .bind(c.req.param('id'))
    .first()
  if (!log) return c.json({ error: 'Not found' }, 404)
  return c.json(log)
})

8. 日志聚合与统计

按天统计调用量和费用

// src/routes/log-stats.ts
logQueryApp.get('/api/logs/stats/daily', async (c) => {
  const days = Number(c.req.query('days') ?? '7')
  const { results } = await c.env.DB.prepare(`
    SELECT
      substr(timestamp, 1, 10) as date,
      model,
      COUNT(*) as total_calls,
      SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as success_calls,
      SUM(total_tokens) as total_tokens_used,
      ROUND(SUM(cost), 4) as total_cost,
      ROUND(AVG(latency_ms), 0) as avg_latency_ms
    FROM model_invocation_logs
    WHERE timestamp >= datetime('now', '-' || ? || ' days')
    GROUP BY date, model
    ORDER BY date DESC
  `).bind(days).all()
  return c.json({ data: results })
})

total_callstotal_costavg_latency_mserror_calls 是四个最常用的监控维度,可以直接对接仪表盘。

用 KV 缓存统计结果

聚合查询有计算开销。把结果缓存 60 秒:

// src/routes/log-stats.ts
async function getCachedStats(kv: KVNamespace, key: string, fetcher: () => Promise<unknown>) {
  const cached = await kv.get(key, 'json')
  if (cached) return cached
  const fresh = await fetcher()
  await kv.put(key, JSON.stringify(fresh), { expirationTtl: 60 })
  return fresh
}

9. 敏感信息脱敏

用户可能在 Prompt 中发送手机号、银行卡号、密码等敏感信息。明文写入日志有泄露风险。

正则替换脱敏

适合不需要回看原文的场景:

// src/logging/sanitize.ts
function sanitizeText(text: string): string {
  let s = text
  s = s.replace(/1[3-9]\d{9}/g, '****')                          // 手机号
  s = s.replace(/(\d{4})\d{10}(\d{4})/g, '$1**********$2')       // 身份证号
  s = s.replace(/([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.\w+)/g, '****@$2')  // 邮箱
  s = s.replace(/(Bearer\s+|api[_-]?key[=:]\s*)[\w.-]+/gi, '$1****')       // Token/Key
  return s
}

加密存储

需要回看原文但有权限控制的场景:

// src/logging/encrypt.ts
async function encryptField(text: string, key: CryptoKey): Promise<string> {
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(text)
  )
  // iv + 密文一起存储,解密时需要 iv
  const combined = new Uint8Array(iv.length + encrypted.byteLength)
  combined.set(iv)
  combined.set(new Uint8Array(encrypted), iv.length)
  return btoa(String.fromCharCode(...combined))
}

选择策略取决于业务需求:不需要回看 → 正则脱敏;需要权限控制 → 加密存储;合规严格 → 双重处理。

10. 生命周期管理

日志会持续增长,需要清理策略。

分级保留

  • Prompt/Completion 全文 —— 保留 30 天
  • 日志元数据(requestId、userId、model、tokens、cost)—— 保留 90 天
  • 统计数据(日调用量、费用汇总)—— 长期保留
// src/jobs/log-cleanup.ts
async function cleanupOldLogs(db: D1Database): Promise<void> {
  // 清除 30 天前的全文,保留元数据
  await db.prepare(`UPDATE model_invocation_logs SET messages = NULL, completion = NULL
    WHERE timestamp < datetime('now', '-30 days') AND messages IS NOT NULL`).run()
  // 清除 90 天前的所有记录
  await db.prepare(`DELETE FROM model_invocation_logs
    WHERE timestamp < datetime('now', '-90 days')`).run()
}

通过 Cron Triggers 每天凌晨执行:&#123; "triggers": &#123; "crons": ["0 3 * * *"] &#125; &#125;

容量估算

日均 10,000 次调用,元数据约 500 bytes/条,全文约 2 KB/条。30 天全文约 600 MB,90 天元数据约 450 MB,总共约 1 GB,在 D1 免费额度(10 GB)内。日均 10 万次以上时,考虑把全文迁移到 R2。

总结

回顾这篇的要点:

  • 模型调用日志记录每次调用的输入、输出、成本和耗时
  • 字段分三类:请求上下文、响应结果、运行时指标
  • 费用通过 Token × 单价预先计算
  • D1 适合 SQL 查询和统计,KV 适合简单存储
  • 日志写入用 waitUntil 异步执行,不阻塞主请求
  • 列表接口不返回大字段,详情接口才返回完整内容
  • 聚合统计为仪表盘和告警提供数据基础
  • Prompt/Completion 中的敏感信息需要脱敏或加密
  • 日志分级保留:全文 30 天、元数据 90 天

下一篇讨论 Prompt 工程相关的后端支持——怎么在后端管理和版本化 Prompt 模板。

所属分组

12-Hono开发AI API服务