--- type: schema node_id: schema:llm-calls title: "表结构: llm_calls(遥测 26 字段)" date: 2026-07-20 --- # 表结构: llm_calls(遥测 26 字段) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) | 列 | 类型 | 说明 | |---|---|---| | call_id | TEXT PRIMARY KEY | 每次尝试独立 UUID;INSERT OR IGNORE 幂等 | | parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) | | model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) | | messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) | | prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;帧缺失记 0/0(不编造估值,由 usage_source 标注) | | usage_source | TEXT NOT NULL | measured / estimated / unavailable(2026-07-30 起三态,见下) | | latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 | | ttft_ms / max_inter_token_ms | REAL | 流式活性测量 | | cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 | | error | TEXT | 异常信息;取消记 "cancelled" | | cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) | | created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 | | cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | | sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 | | reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 | | tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 | | meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB | | thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 | | reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 | ## usage/成本口径(2026-07-30,est_tokens 解耦) | usage_source | 含义 | 生产者 | cost | |---|---|---|---| | `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 | | `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 | | `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL | `SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。 ## 供应商 prompt cache 口径(2026-07-31,issue #3) 新增两列排在 `created_at` **之后**——旧表只能经 `ALTER TABLE ADD COLUMN` 追加到末尾,DDL 里若插在前面,新建库与升级库的物理列序会分叉(列序断言无合规修法)。两个后端在初始化期幂等补列:`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入被逐行 warning 丢弃、遥测静默全失。两侧都**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,遥测是内联 await,锁共享审计表会拖垮业务调用),且**补列失败只降级为逐行丢弃,绝不让 recorder 整体失能**——两侧纪律必须对称。 `cache_hit` 指 **PolyGateway 自身响应缓存**,与供应商 prompt cache 是两回事。缓存命中行的这两列是**原样回放**的历史值(与 `model`/`prompt_tokens` 同一口径),故命中率度量口径固定为: ```sql SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0) FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; ``` `WHERE cache_hit = false` 不可省,理由与上面 cost 缺口口径同源:回放行计入即重复计数。 ## 采样参数口径(2026-07-31,issue #4) `reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故当时的统计口径是 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。 > **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。 `sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。 三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同: | 入口 | 调用者 | 有生效源? | 记什么 | |---|---|---|---| | `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` | | `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` | | `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` | 后两行缺 `extra_body` 是客观事实而非口径瑕疵——它们没有"生效源"可言,与 `model`/`source_name` 在终态行置空是同一先例;缓存命中行亦无损:`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。 OCR / embedding 路径的该列**恒为 NULL**:两条路径的 transport 不发 `extra_body`(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart),故其源在构造期就被剥离——不剥离则该列会记录一个从未发出的参数,那是数据造假而非参数失效。 复现某批实验的解码条件: ```sql SELECT DISTINCT sampling FROM llm_calls WHERE session_id = $1 AND cache_hit = false AND error IS NULL; ``` ## 推理观测口径(2026-08-25,issue #16/#17) `thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0` 亦 `observed`;`reasoning_tokens == 0` 为 `absent`(上游明确上报未推理);两个信号双缺为 `unknown`。 **`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。 按模型看各观测态占比,用于发现某模型从哪天起观测不到推理: ```sql SELECT model, thinking_observation, count(*) AS calls, round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct FROM llm_calls WHERE cache_hit = false AND error IS NULL AND created_at >= now() - interval '7 days' GROUP BY model, thinking_observation ORDER BY model, calls DESC; ``` 三条限定各有理由: `cache_hit = false` 与 `cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。 ## 推理档位口径(2026-09-04,issue #20) `reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。 三个 emit 入口的取值同样各自定死,与 `sampling` 同构: | 入口 | 有生效源? | 记什么 | |---|---|---| | `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** | | `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** | | `emit_cache_hit` / `emit_terminal_failure` | 无 | 仅 `request.reasoning_effort` | 成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。 OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。 按档位看推理产出,即压测「高档是不是真的多想」的基本查询: ```sql SELECT model, reasoning_effort, count(*) AS calls, round(avg(reasoning_tokens)) AS avg_reasoning_tokens FROM llm_calls WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL GROUP BY model, reasoning_effort ORDER BY model, calls DESC; ``` ## 埋点位置(单一 helper 铁律) - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; - `TelemetryMW`(最外层)记缓存命中与最终失败;`RetryMW._emit` 经同一 Emitter 逐次记尝试; - 写失败降级 warning 不冒泡;取消路径 finally 尽力记录。 ## 评估基线 首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。