模型调用日志
要点
- 模型调用日志记录每次大模型 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、error | model、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 })
})列表接口不返回 messages 和 completion——一条带完整内容的日志可能有几十 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_calls、total_cost、avg_latency_ms、error_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 每天凌晨执行:{ "triggers": { "crons": ["0 3 * * *"] } }
容量估算
日均 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服务