Twenty-five columns and not one of them answered "which tier was this?", so the question the whole issue exists to settle - does a higher tier buy anything - had no way to group its data. The three emit entry points deliberately disagree, the way sampling already does. A successful attempt records what the transport actually sent: with EFFORT_FALLBACK=nearest a request for medium goes out as low, and recomputing here would file the row under a tier that never left the process. A failed attempt has no response to read, so it falls back to the requested tier - which is exactly right for the tier errors that are rejected before any HTTP happens, because the rejected tier is the signal. Cache hits and terminal failures have no chosen source at all, so a source-level tier is not a thing they could report. emit_attempt now demands to be told whether the path reasons at all. Embedding and OCR share the emitter but never send reasoning parameters; without the flag a source that mistakenly carries ENABLE_THINKING would hang a tier on a call that could not possibly have run at one. The value lands as a plain str. StrEnum is a str subclass and asyncpg promises nothing about encoding subclasses, and a telemetry write that fails is only a warning - Postgres would just quietly lose the column. NULL means nobody declared a tier, which is not the same statement as 'none', and the two must never be folded together.
12 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| schema | schema:llm-calls | 表结构: llm_calls(遥测 26 字段) | 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 同一口径),故命中率度量口径固定为:
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),故其源在构造期就被剥离——不剥离则该列会记录一个从未发出的参数,那是数据造假而非参数失效。
复现某批实验的解码条件:
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,任何把它读成「没推理」的报表都在撒谎。
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
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,记一个档也是记录一个从未发出的参数。
按档位看推理产出,即压测「高档是不是真的多想」的基本查询:
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 协程写全落库。