OpenRouter Dashboard 反查 Runbook¶
Service: shared AI invoke (lambda/shared/utils/ai/invoke.ts) + OpenRouter dashboard
Last Updated: 2026-05-20
Owner: Platform Team
Related Docs: AI Analysis Runbook | Pipeline Monitoring
前置 PR: callytics-infrastructure #966(response-healing + usage telemetry)+ #967(generation ID + provider in log)
这个 runbook 解决什么问题¶
主场景:客户在 Studio (retaintive dashboard) 上看到一通有问题的 call — task 抓错、staff name 错、call_state 离谱、cost 异常。客户给你的信息通常只有电话号码 + 时间,没有 telephonySessionId、更没有 OpenRouter Generation ID。
需要从电话号码反向走 4 步追到 OpenRouter 那一次具体调用的完整 payload。
反例:如果只是想看整体成本 / 模型使用 → 直接打开 openrouter.ai/activity 看聚合就行,不用走这套反查。
4-Step 反查路径(~2 分钟)¶
┌──────────────────────────────────────────────────────────────┐
│ Step 1: Studio (retaintive dashboard) │
│ ─────────────────────────────────────────────────────────── │
│ 客户报告"这通分析不对" → 拿到 phone number + 大概时间 │
│ 例: +19142654371, 2026-05-20 13:15 │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Step 2: Neon `calls` 表查 telephony_session_id │
│ ─────────────────────────────────────────────────────────── │
│ 用 from_phone_number / to_phone_number / contact_phone 查 │
│ → 拿到 telephony_session_id + created_at │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Step 3: CloudWatch Logs Insights 拿 openrouterId │
│ ─────────────────────────────────────────────────────────── │
│ filter @message like /ai_usage/ │
│ filter telephonySessionId = "abc123" │
│ → 拿到 openrouterId = "gen-1779299823-8nd5Ci0U..." │
│ (3-stage pipeline = 3 行 = 3 个 openrouterId) │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Step 4: OpenRouter Activity Page │
│ ─────────────────────────────────────────────────────────── │
│ openrouter.ai/activity/{openrouterId} │
│ → 完整 payload + provider routing + cache status + 计费 │
└──────────────────────────────────────────────────────────────┘
Step 1: Studio dashboard 拿电话号码¶
客户报告:"5 月 20 号下午 1 点多有通 +1 (914) 265-4371 的电话,AI 分析说是 voicemail 但其实是真人对话。"
你需要从客户报告里提取:
| 信息 | 用在哪 | 必须吗 |
|---|---|---|
| Phone number | Neon calls 表 phone 列查询 |
必须 |
| 大概时间 | 时间窗口缩小到 ±2h,避免历史撞号 | 强烈推荐 |
| Store / Franchise | 进一步过滤 | 可选 |
| 通话方向(接 / 打) | 决定查 from_phone_number 还是 to_phone_number |
可选 |
Phone 格式规范化:Neon 里所有电话都是 E.164 格式(+19142654371,前面带 +1)。客户给的 (914) 265-4371 / 914-265-4371 / 19142654371 都要先转 E.164 再查。
# 简单 normalize(去掉所有非数字 + 前置 +1)
echo "(914) 265-4371" | tr -cd '0-9' | sed 's/^/+1/'
# → +19142654371
Step 2: Neon calls 表查 telephonysessionid¶
Neon 表结构(相关字段)¶
| 字段 | 类型 | 含义 |
|---|---|---|
telephony_session_id |
text | 反查需要的 ID(一通电话唯一) |
from_phone_number |
text (E.164) | 主叫号码 |
to_phone_number |
text (E.164) | 被叫号码 |
contact_phone |
text (E.164) | 客户号码(接 vs 打无关) |
store_id |
uuid | 店铺隔离键 |
franchise_id |
text | 品牌 |
created_at |
timestamp | 通话创建时间 |
s3_analysis_path |
text | AI 分析结果 S3 路径(已分析非 null) |
来源:@retaintive/common/src/db/schema/calls.ts。
Query:按客户电话 + 时间窗¶
SELECT
telephony_session_id,
store_id,
franchise_id,
from_phone_number,
to_phone_number,
contact_phone,
created_at,
s3_analysis_path
FROM calls
WHERE contact_phone = '+19142654371'
AND created_at BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY created_at DESC;
- 用
contact_phone字段最稳——不管接还是打,客户号码都在这一列 - 时间窗 ±2h 避免命中历史同号通话
- 如果同时段多通 → 让客户进一步描述("是早上还是下午?接的还是打的?")
如果不确定客户电话归在哪一列¶
也可以用 from / to 任一边匹配兜底:
SELECT
telephony_session_id,
from_phone_number,
to_phone_number,
contact_phone,
created_at
FROM calls
WHERE (from_phone_number = '+19142654371' OR to_phone_number = '+19142654371')
AND created_at BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY created_at DESC;
Neon 怎么连¶
| 方式 | 适合谁 |
|---|---|
| Neon Console SQL Editor (https://console.neon.tech) | 偶尔查、不想搭客户端 |
psql (CLI) |
重度查询、需要 export |
| 项目 IDE 集成 (Drizzle Studio / DBeaver) | 长期 |
连接串在 SSM Parameter Store。Test 环境路径 / 凭据找 Platform team 拿,不要把 connection string commit 进 git。
Step 3: CloudWatch Insights 拿 OpenRouter Generation ID¶
选对 Log Group¶
| Lambda | Log Group 模式 |
|---|---|
| ai-analysis-processor | /aws/lambda/call-analytics-{env}-ai-analysis-processor-{region} |
| contacts-analyzer | /aws/lambda/call-analytics-{env}-contacts-analyzer-{region} |
{env} = test / pre / prod,{region} = us-west-2 / us-east-2 / us-east-1。实际名字以 AWS Console "Log groups" 列表为准。
Query:按 telephonySessionId 反查¶
fields @timestamp, openrouterId, provider, model, inputTokens, outputTokens,
cacheReadTokens, cacheReadRatio, costUsd, finishReason
| filter @message like /ai_usage/
| filter telephonySessionId = "abc123"
| sort @timestamp asc
正常情况返回 3 行(ai-analysis 3-stage pipeline: triage → classify → coaching)。每行有自己的 openrouterId。
ai_usage 字段对照表¶
| 字段 | 类型 | 含义 | SDK 路径 |
|---|---|---|---|
openrouterId |
string (可能 undefined) | OpenRouter Generation ID,格式 gen-{timestamp}-{rand} |
result.response.id |
provider |
string (可能 undefined) | 上游 provider,如 "AtlasCloud" / "Alibaba Cloud Int" |
result.providerMetadata.openrouter.provider |
model |
string | 模型 ID,如 "deepseek/deepseek-v4-flash" |
我们传的 |
inputTokens |
number | 总 input token 数 | result.usage.inputTokens |
outputTokens |
number | 总 output token 数 | result.usage.outputTokens |
cacheReadTokens |
number (可能 undefined) | 命中 cache 的 input token | result.usage.inputTokenDetails.cacheReadTokens |
cacheReadRatio |
number (0-1) | cacheReadTokens / inputTokens (3-decimal) |
invoke.ts 计算 |
costUsd |
number (可能 undefined) | OpenRouter 已计费的金额 | providerMetadata.openrouter.usage.cost |
finishReason |
string | "stop" / "length" / "content-filter" 等 |
result.finishReason |
⚠️ openrouterId 偶尔可能 undefined(SDK shape 变 / provider 异常 / log 抽取失败)。如果反查不到 → 用 fallback 流程(章节 当 openrouterId 缺失)。
Step 4: OpenRouter Activity Page¶
直接打开 URL¶
例:https://openrouter.ai/activity/gen-1779299823-8nd5Ci0U0mwlGRruPcXq
页面叫 Generation details,会显示:
| Section | 内容 |
|---|---|
| Header | 模型 + provider + Cached badge(如果命中) |
| 顶部 metrics | Provider latency / Throughput tok/s / Cost USD / Tokens 拼接 / Fallbacks |
| Overview | Model ID / Canonical ID / Data policy |
| Request | Request ID(开发用)/ Generation ID(URL 里那个)/ Finish reason / Streaming |
| Provider routing | 实际走的 provider 链 + fallback 历史 |
| Tokens | input/output details, cache breakdown |
| Generation params | temperature / maxtokens / responseformat 等实际发送参数 |
没权限?¶
OpenRouter Activity 页要求 API key 所有者账号登录。没账号 → 联系 platform team 拿 read access,或让有权限的人代查。
备用 API(无 UI 也能查)¶
# 从 SSM 临时取 API key
OPENROUTER_API_KEY=$(aws ssm get-parameter \
--name /callytics/openrouter-api-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
# 查 generation
curl -s -H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
"https://openrouter.ai/api/v1/generation?id=gen-1779299823-8nd5Ci0U0mwlGRruPcXq" | jq .
# 用完清掉
unset OPENROUTER_API_KEY
⚠️ API key 别 commit 进 git / 别贴进 chat。用完立刻清,shell history 也清。
完整示例:从客户报告到 OpenRouter¶
客户原话¶
"2026-05-20 下午 1:15 左右有通电话 (914) 265-4371,你们的 AI 分析说是 voicemail 但其实是真人在说话。"
Step 1 — normalize 电话¶
Step 2 — Neon 查¶
SELECT telephony_session_id, store_id, created_at, s3_analysis_path
FROM calls
WHERE contact_phone = '+19142654371'
AND created_at BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY created_at DESC;
返回:
telephony_session_id | store_id | created_at | s3_analysis_path
s-abc123def456 | a1b2c3d4-... | 2026-05-20 13:14:52 | s3://...
→ telephony_session_id = 's-abc123def456'
Step 3 — CloudWatch Insights¶
Log group: /aws/lambda/call-analytics-prod-ai-analysis-processor-us-east-1
fields @timestamp, openrouterId, provider, model, cacheReadRatio, costUsd, finishReason
| filter @message like /ai_usage/
| filter telephonySessionId = "s-abc123def456"
| sort @timestamp asc
返回 3 行(triage / classify / coaching):
@timestamp | openrouterId | provider | cacheReadRatio | costUsd | finishReason
2026-05-20 13:14:55 | gen-1779299695-aBcDeFg | AtlasCloud | 0.92 | 0.000122 | stop
2026-05-20 13:14:57 | gen-1779299697-hIjKlMn | AtlasCloud | 0.88 | 0.000774 | stop
2026-05-20 13:15:00 | gen-1779299700-oPqRsTu | AtlasCloud | 0.85 | 0.000639 | stop
Step 4 — OpenRouter¶
打开第一行(triage):https://openrouter.ai/activity/gen-1779299695-aBcDeFg
Generation details 页面看:
- 输入 transcript 是不是被截断了
- 模型实际返回的 raw JSON 是不是
{"call_state": "voicemail", ...} - finish_reason 是不是
stop
如果 raw JSON 就是 voicemail → 模型判断错了,prompt 问题或transcript 转录质量问题(去对 transcribe-processor 的输出)。
如果 raw JSON 是 human_conversation 但 Neon 存的是 voicemail → 下游 mapping bug(去查 ai-analysis-processor 的 post-process 逻辑)。
当 openrouterId 缺失¶
如果 CloudWatch ai_usage 行里 openrouterId 是 undefined(SDK shape 变 / provider 异常 / log 抽取失败),走 degraded path:
- 拿同一行的 4 元组
(timestamp, inputTokens, outputTokens, costUsd) - 打开 openrouter.ai/activity
- App filter 选对应 Lambda(
callytics-ai-analysis/callytics-contacts-analyzer) - 时间窗口卡到目标秒附近 ±10s
- 用 token 数 + cost 在 dashboard 行里手动对
⚠️ 同 store 同时段重复 transcript 可能撞数字。这是 degraded path,频繁触发说明 telemetry 出问题,给 platform team 报。
Dashboard 整体观察(不针对单 call)¶
定期巡检 / 看大盘时用,不是 per-call 反查。
打开¶
https://openrouter.ai/activity — 默认显示账号下所有 generation,时间倒序。
关键 filter¶
| Filter | 用法 | 用在哪 |
|---|---|---|
| App | 选 callytics-ai-analysis 或 callytics-contacts-analyzer |
分开看两个 Lambda 流量 |
| Model | 选 deepseek/deepseek-v4-flash |
跟其他模型流量混了的话隔离 |
| Provider | 选 AtlasCloud / Alibaba Cloud Int 等 |
看 sticky routing 是否在工作 |
| Time range | 6h / 24h / 7d / 自定义 | 圈定调查窗口 |
看什么¶
- Cached badge 出现率:DeepSeek implicit cache hit rate 应该 90%+(社区数据)。整列长期没 Cached badge → prompt prefix 飘了,去 grep
prompts.ts找${...}插值 - Provider 列分布:sticky routing 应该把同 prompt 路由到同 provider 最大化 cache。频繁切换 → 报告给 platform team
- Finish reason 异常:
length多 → maxTokens 设太小;content-filter多 → prompt 触发 moderation,检查 transcript 内容
6 个常见 debug 场景¶
场景 1:客户报告"某通分析全错"¶
照 4-step 流程走完。重点看 Generation details 的 raw response:
- raw response 跟 Neon 存的一致 → 模型判断错(prompt 或 transcript 质量问题)
- raw response 跟 Neon 存的不一致 → 下游 mapping bug
场景 2:某段时间 cost 异常高¶
fields @timestamp, openrouterId, costUsd, cacheReadRatio, inputTokens
| filter @message like /ai_usage/
| filter @timestamp > now() - 2h
| sort costUsd desc
| limit 20
拿 top costUsd 行的 openrouterId → Activity 页看:
inputTokens暴涨 → transcript 太长(去对 transcribe-processor 看是不是漏切)cacheReadRatio = 0→ cache miss(prompt 飘了 / cold start / sticky routing 没生效)
场景 3:cache 命中率长期是 0¶
fields @timestamp, model, cacheReadRatio
| filter @message like /ai_usage/
| stats avg(cacheReadRatio) as avg_hit by bin(1h)
连续多小时 avg_hit ≈ 0:
- 检查
lambda/ai-analysis-processor/src/core/stages/prompts.ts有没人加${...}插值 - 跑
cd lambda/ai-analysis-processor && npm test tests/unit/prompts-cache-invariant.test.ts,测试应该挂;如果没挂说明 prompt 改动绕过了 invariant test
场景 4:dashboard 显示 "Unknown" App¶
PR #967 已合并 + deploy 但 dashboard 还看到 Unknown → Lambda 还在跑旧版本:
# 看 Lambda 当前部署的 commit
aws lambda get-function \
--function-name call-analytics-test-ai-analysis-processor-us-west-2 \
--query 'Configuration.CodeSha256'
# 强制重新部署
cd ~/workspace/callytics-infrastructure
npx cdk deploy CallAnalytics-Test-Lambda --require-approval never
场景 5:finishReason 是 length¶
输出被截断。Activity 页看 maxTokens 实际值,对比 Lambda 配置:
| Lambda | maxTokens 当前值 | 配置位置 |
|---|---|---|
| ai-analysis-processor | 4096 | lambda/ai-analysis-processor/src/infrastructure/ai/invoke-model.ts |
| contacts-analyzer | 4000 | lambda/contacts-analyzer/src/infrastructure/ai-client.ts |
如果 transcript 极长且需要长输出 → 提 PR 改 maxTokens;如果偶发 → 看 input 是不是异常长的 transcript。
场景 6:provider 频繁切换 / fallback 多¶
Activity 页 Provider routing section 显示 fallback 链。某 primary provider 频繁 fallback:
- OpenRouter dashboard 看 Provider 列分布
- sticky routing 失效 → 联系 OpenRouter support(平台层)
- 短期 mitigation:在
lambda/shared/utils/ai/invoke.ts的createOpenRouter()加provider: { order: ['AtlasCloud'], allow_fallbacks: false }(会牺牲 availability,先讨论再做)
Identity 字段速查(哪个 ID 反查什么)¶
| ID 字段 | 哪儿能看到 | 反查什么 |
|---|---|---|
| Phone number (E.164) | Studio dashboard / Neon calls.from_phone_number, to_phone_number, contact_phone |
客户视角入口 |
telephonySessionId |
Neon calls.telephony_session_id / DDB call-analysis PK / CloudWatch loggerContext |
一通电话的完整 pipeline 痕迹 |
openrouterId (gen-xxx) |
CloudWatch ai_usage.openrouterId / OpenRouter dashboard "Generation ID" 列 |
OpenRouter 一次 AI call 的完整 payload + routing |
requestId (req-xxx) |
OpenRouter dashboard "Request ID"(Activity 页) | OpenRouter 内部 trace(开发用,operator 通常不用) |
awsRequestId |
CloudWatch loggerContext.requestId / context.awsRequestId |
Lambda 一次 invocation 的所有 log |
关键关系:
- 1 个 phone number 多对多 telephonysessionid(同号码可以打多次电话)
- 1 个 telephonysessionid 一对多 openrouterId(一通电话触发 3 次 AI call)
- 1 个 openrouterId 永远对应唯一 1 通
哪儿改 log 字段¶
只需要知道源在哪 / 想改 log 字段的人去看:
| 关注点 | 文件路径(callytics-infrastructure) |
|---|---|
| Generation ID 提取 | lambda/shared/utils/ai/invoke.ts 的 logUsage() 函数 |
| App 名传给 OpenRouter | lambda/shared/utils/ai/invoke.ts 的 createOpenRouter({ appName, appUrl }) |
| ai-analysis App 名 | lambda/ai-analysis-processor/src/infrastructure/ai/invoke-model.ts const APP_NAME |
| contacts App 名 | lambda/contacts-analyzer/src/infrastructure/ai-client.ts const APP_NAME |
| Cache invariant test | lambda/ai-analysis-processor/tests/unit/prompts-cache-invariant.test.ts |
相关链接¶
- OpenRouter dashboard: https://openrouter.ai/activity
- OpenRouter API Reference: https://openrouter.ai/docs/api/reference/overview
- Prompt Caching 解释: https://openrouter.ai/docs/features/prompt-caching
- Usage Accounting 解释: https://openrouter.ai/docs/use-cases/usage-accounting
- AI Analysis Runbook: ai-analysis.md
- Pipeline Monitoring: pipeline-monitoring.md
- Reprocess AI Analysis SOP: ../reprocess-ai-analysis.md
- Neon
calls表 schema:@retaintive/common/src/db/schema/calls.ts
历史 / 设计决策¶
为什么不存 requestId? OpenRouter 里 Request ID(req-xxx)是开发用的内部 trace,operator 反查永远用 Generation ID(gen-xxx),所以我们只 log 后者。如果有一天发现需要 requestId → 改 lambda/shared/utils/ai/invoke.ts 的 logUsage() 加字段。
为什么 App 名是 callytics-ai-analysis 不是 callytics-ai-analysis-triage / -classify / -coaching? stage 信息走 CloudWatch(哪个 stage 在调用 logger 看 service 字段就知道),dashboard App 维度只到 Lambda 粒度,避免把 dashboard column 拆得过细。
为什么入口是电话号码不是 telephonySessionId? 客户视角永远从 Studio dashboard 开始,他们看不到 telephonySessionId(那是后端 ID)。Engineer 视角已知 telephonySessionId 时可以从 Step 2 开始跳过 Step 1。