b4812e12c8
终态出口 `emit_terminal_once` 此前只让 `_record` 内的 except 兜住落库,而 诊断字段的提取(`_error_fields` → `_structured_detail` → `format_bounded_errors`) 在降级 try **之外**求值。下游经公共端口(自实现 `StructuredOutputStrategy` 或 transport)构造出 `ResultInvalidError(validation_errors=(非 str,))` 时,提取期 抛的 `TypeError` 会顶替调用方本该收到的领域异常——错误四分类被击穿(下游 `except ResultInvalidError` 落空),且 `claim_terminal()` 已消耗故终态行照样丢, 同时违反"遥测写失败降级不冒泡"。 改法与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款: 把快照冻结与 await 整段包进 try,`CancelledError` 原样上抛、其余落一条 warning。终态行按已批准的 best effort(兜底命中时该次逻辑调用 0 条终态行,不补写)。异常类型校验与 `ResultInvalidError` 的既有设计均未改动。 顺带同步审查报告的 Minor 项: SQLite recorder docstring 26 → 36 字段、 research-wiki 索引重建、ARCHITECTURE 的 `sampling` 段落终态调用点口径, 并删除 `TelemetryMW` 迁移后无读取点的 `self._now` 死字段(保留形参, 避免平白打断既有装配写法)。
736 lines
34 KiB
Python
736 lines
34 KiB
Python
"""TelemetryMW + TelemetryEmitter: 遥测调用点收敛为单一 helper(铁律)。
|
|
|
|
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
|
|
15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW
|
|
(最外层)只记尝试层看不见的缓存命中;而**终态失败行**由三个 client 的公开
|
|
边界经 `emit_terminal_once` 统一写出(1.3.5)——两处同时写就会双计。
|
|
|
|
一行遥测属于三类事件之一(`event_kind`): `attempt`(一次尝试)、`cache_hit`
|
|
(未产生网关调用)、`terminal_failure`(一次**逻辑调用**的失败终态)。后两者与
|
|
前者**不是重复事实**,故统计失败调用次数只能取 `terminal_failure`,
|
|
不得按 `error IS NOT NULL` 跨两类直接计数(设计 §6/§8)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from loguru import logger
|
|
|
|
from polygateway.errors import PolyGatewayError, ResultInvalidError
|
|
from polygateway.middleware.cache import digest_messages
|
|
from polygateway.middleware.structured import MAX_ERROR_CHARS, format_bounded_errors
|
|
from polygateway.ports import TelemetryRecorder
|
|
from polygateway.thinking import effective_effort
|
|
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Mapping
|
|
from typing import Any
|
|
|
|
from polygateway.ports import CallNext
|
|
from polygateway.pricing import PricingTable
|
|
from polygateway.types import (
|
|
CallOperation,
|
|
CallStats,
|
|
ChatRequest,
|
|
EventKind,
|
|
LLMResponse,
|
|
SourceConfig,
|
|
_CallContext,
|
|
)
|
|
|
|
|
|
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
|
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
|
|
|
|
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
|
|
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
|
|
|
|
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
|
|
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
|
|
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
|
|
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
|
|
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
|
|
|
|
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
|
|
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
|
|
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。
|
|
"""
|
|
if not meta:
|
|
return "{}"
|
|
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
|
|
|
|
|
def _normalize_observation(raw: object) -> str:
|
|
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
|
|
|
|
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
|
|
(尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")`
|
|
完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record`
|
|
的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而
|
|
"遥测必录"是铁律。
|
|
|
|
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
|
|
落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身
|
|
一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独
|
|
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。
|
|
"""
|
|
try:
|
|
return ThinkingObservation(raw).value
|
|
except ValueError:
|
|
logger.warning(
|
|
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
|
|
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
|
|
raw,
|
|
)
|
|
return ThinkingObservation.UNKNOWN.value
|
|
|
|
|
|
def _normalize_effort(raw: object) -> str | None:
|
|
"""实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。
|
|
|
|
**不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse`
|
|
是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然,
|
|
而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成
|
|
一条泛化 warning —— 丢的不是这一列,是**整行**。
|
|
|
|
域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致
|
|
(设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名,
|
|
归因字段不该有能力废掉一整行遥测。降级到 `None` 也是唯一诚实的说法——库确实
|
|
不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事。
|
|
|
|
注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`:
|
|
`'none'` 是"明确要求不推理",是一次表态。
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return Effort(raw).value
|
|
except ValueError:
|
|
logger.warning(
|
|
"推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL"
|
|
"(其余列照常落库)",
|
|
raw,
|
|
)
|
|
return None
|
|
|
|
|
|
def _attempt_effort(
|
|
*,
|
|
request: ChatRequest,
|
|
source: SourceConfig,
|
|
response: LLMResponse | None,
|
|
applies: bool,
|
|
) -> str | None:
|
|
"""一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。
|
|
|
|
成功行一律读 `response.applied_effort` 而**绝不重算**: 源上开了
|
|
`EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实发的是
|
|
`low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出
|
|
过的分组下——而两个值在没开映射的源上恒等,这个错在本地跑不出来。
|
|
|
|
失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射
|
|
的源上,成功行是映射后的档、失败行是请求档,两种行不是同一把尺子。仍然记而不是
|
|
留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,
|
|
这类行记的正是**被拒绝的那一档**——"哪一档配错了"是压测与排障要的信号。
|
|
|
|
回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态
|
|
(语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"。
|
|
"""
|
|
if not applies:
|
|
return None
|
|
if response is not None:
|
|
return _normalize_effort(response.applied_effort)
|
|
return _normalize_effort(
|
|
effective_effort(
|
|
request_effort=request.reasoning_effort,
|
|
source_effort=source.reasoning_effort,
|
|
enable_thinking=source.enable_thinking,
|
|
)
|
|
)
|
|
|
|
|
|
def _cap_text(text: str, cap: int | None) -> str:
|
|
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
|
if cap is None or len(text) <= cap:
|
|
return text
|
|
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
|
|
|
|
|
|
def _cap_part(part: Any, cap: int) -> Any:
|
|
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
|
|
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
|
return {**part, "text": _cap_text(part["text"], cap)}
|
|
return part
|
|
|
|
|
|
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
|
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
|
|
|
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
|
|
|
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
|
|
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
|
|
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
|
|
且全程无任何报错。
|
|
"""
|
|
if cap is None:
|
|
return messages
|
|
capped: list[dict[str, Any]] = []
|
|
for msg in messages:
|
|
content = msg.get("content")
|
|
if isinstance(content, str):
|
|
capped.append({**msg, "content": _cap_text(content, cap)})
|
|
elif isinstance(content, list):
|
|
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
|
|
else:
|
|
capped.append(msg)
|
|
return capped
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _AttemptUsage:
|
|
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
|
|
|
存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为
|
|
一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。
|
|
"""
|
|
|
|
response_text: str = ""
|
|
thinking: str = ""
|
|
prompt_tokens: int = 0
|
|
completion_tokens: int = 0
|
|
usage_source: str = "unavailable"
|
|
ttft_ms: float | None = None
|
|
max_inter_token_ms: float | None = None
|
|
cached_prompt_tokens: int | None = None
|
|
model_reported: str | None = None
|
|
reasoning_tokens: int | None = None
|
|
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
|
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
|
|
|
@classmethod
|
|
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
|
"""从响应取用量;`None`(失败尝试)返回全默认视图。"""
|
|
if response is None:
|
|
return cls()
|
|
return cls(
|
|
response_text=response.content,
|
|
thinking=response.thinking,
|
|
prompt_tokens=response.prompt_tokens,
|
|
completion_tokens=response.completion_tokens,
|
|
usage_source=response.usage_source,
|
|
ttft_ms=response.ttft_ms,
|
|
max_inter_token_ms=response.max_inter_token_ms,
|
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
|
model_reported=response.model_reported,
|
|
reasoning_tokens=response.reasoning_tokens,
|
|
thinking_observation=response.thinking_observation,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ErrorFields:
|
|
"""一行遥测的错误列;未知一律 `None`。
|
|
|
|
存在的理由是把"三种入参形态 × 两类行"的定型规则收敛到**一处**:
|
|
改前调用方先 `str(exc)` 压平,状态码、底层异常类型与网关正文全部丢失。
|
|
"""
|
|
|
|
error: str | None = None
|
|
error_type: str | None = None
|
|
cause_type: str | None = None
|
|
http_status_code: int | None = None
|
|
error_body: str | None = None
|
|
|
|
|
|
def _structured_detail(exc: ResultInvalidError) -> str:
|
|
"""结构化阶梯耗尽的**有界**说明(设计 §5 C2)。
|
|
|
|
`ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含校验与修复错误,而该
|
|
失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是**成功行**,终态行是
|
|
唯一记录。故把说明并入现有 `error` 串。
|
|
|
|
**不含 `raw_text`**: 它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过
|
|
一份;再存一份等于绕过既有的正文预算。条数与限长复用 `structured.py` 的同一
|
|
套常量(重问反馈与本说明同一口径),数值只有一份。
|
|
"""
|
|
parts: list[str] = []
|
|
if exc.repair_error:
|
|
parts.append(f"repair={exc.repair_error[:MAX_ERROR_CHARS]}")
|
|
if exc.validation_errors:
|
|
parts.append(f"validation={format_bounded_errors(exc.validation_errors)}")
|
|
return " | ".join(parts)
|
|
|
|
|
|
def _error_fields(
|
|
error: PolyGatewayError | str | None,
|
|
*,
|
|
event_kind: EventKind,
|
|
class_prefixed: bool,
|
|
) -> _ErrorFields:
|
|
"""三种入参形态的唯一定型点(设计 §5/§6)。
|
|
|
|
- `None` → 全 None(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了")。
|
|
- `str`(取消路径的 `"cancelled"`)→ 原样落 `error`,**不解析字符串猜诊断**。
|
|
- 领域异常 → 只读它既有的属性,不遍历任意对象、不猜正文。
|
|
|
|
**终态行的三列恒为 NULL(C1 红线)**: `GatewayUnavailableError` 家族从不携带
|
|
状态码与响应体,NULL 正是它自身的真实状态——把最后一次 attempt 的状态码与正文
|
|
搬上来,就是拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的
|
|
attempt 行给出。
|
|
"""
|
|
if error is None:
|
|
return _ErrorFields()
|
|
if isinstance(error, str):
|
|
return _ErrorFields(error=error)
|
|
name = type(error).__name__
|
|
# 空 `str()` 退回类名(httpx 的 Connect/Read/Write/PoolTimeout 文案就是空的);
|
|
# `class_prefixed` 是 OCR 的既有口径(按类名归组的 metric),故逐字保留它的拼法
|
|
text = f"{name}: {error}" if class_prefixed else (str(error) or name)
|
|
if event_kind == "terminal_failure":
|
|
if isinstance(error, ResultInvalidError):
|
|
detail = _structured_detail(error)
|
|
if detail:
|
|
text = f"{text} | {detail}"
|
|
return _ErrorFields(error=text, error_type=name)
|
|
cause = error.__cause__
|
|
return _ErrorFields(
|
|
error=text,
|
|
error_type=name,
|
|
cause_type=type(cause).__name__ if cause is not None else None,
|
|
# getattr 而非直读: 本函数在 `_record` 的降级 try **之外**求值,
|
|
# 一个非领域异常误传进来不得把一次真实失败换成 AttributeError
|
|
http_status_code=getattr(error, "status_code", None),
|
|
# 空串归 None: 既有 `body_text` 的缺省就是空串,而本列的语义是"未知"
|
|
error_body=getattr(error, "body_text", "") or None,
|
|
)
|
|
|
|
|
|
def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
|
|
"""装配期一次 `signature.bind` 形状校验: 不执行写入,只证明该形状能被接受。
|
|
|
|
`_record` 的 `except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning,
|
|
后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是
|
|
"遥测必录"要防的形态,而文档级迁移清单挡不住它。故在装配期当场报错
|
|
(不是 warning: 降级方向的铁律管的是**运行期写失败**,不是装配错误)。
|
|
|
|
参数名从 `TelemetryRecorder.record_llm_call` 的协议签名**派生**(不手抄第四份
|
|
字段清单),绑定用哨兵 `None`,不读任何真实请求数据;`**kwargs`
|
|
(VAR_KEYWORD)自动通过。不可 inspect(C 实现等)同样按配置错误报错——宁可
|
|
装配不起来,不进入"运行期静默丢行"。
|
|
|
|
边界诚实声明: 它只证明该形状能被接受,**不能证明函数体真的落这些列**。
|
|
|
|
Raises:
|
|
ValueError: 签名不符、不可 inspect,或协议本身不可 inspect。
|
|
"""
|
|
try:
|
|
# 模块全局查找而非常量快照: 协议改了,闸就跟着改(测试可据此机械验证)
|
|
protocol = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
|
except (TypeError, ValueError) as exc: # pragma: no cover - 协议一向可 inspect
|
|
raise ValueError(f"TelemetryRecorder.record_llm_call 签名不可读取: {exc}") from exc
|
|
sentinels = {name: None for name in protocol if name != "self"}
|
|
label = type(recorder).__name__
|
|
method = getattr(recorder, "record_llm_call", None)
|
|
if method is None:
|
|
# 连方法都没有: 比旧签名更明确的配置错误。不让它以裸 AttributeError
|
|
# 逆流而上——那不属错误四分类,且现场离"注错了东西"这个真因很远
|
|
raise ValueError(
|
|
f"注入的遥测 recorder {label} 没有 record_llm_call 方法,不满足 TelemetryRecorder 端口"
|
|
)
|
|
try:
|
|
signature = inspect.signature(method)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(
|
|
f"遥测 recorder {label} 的 record_llm_call 不可 inspect(如 C 实现),"
|
|
"无法在装配期确认它接受当前字段形状;请换成 Python 实现或包一层"
|
|
) from exc
|
|
try:
|
|
signature.bind(**sentinels)
|
|
except TypeError as exc:
|
|
raise ValueError(
|
|
f"遥测 recorder {label} 的 record_llm_call 签名与 TelemetryRecorder 不符"
|
|
f"(当前 {len(sentinels)} 个字段): {exc}。"
|
|
"这一条故意在装配期报错——放行的后果是每行遥测都被降级成 warning 后丢弃"
|
|
) from exc
|
|
|
|
|
|
class TelemetryEmitter:
|
|
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。"""
|
|
|
|
def __init__(
|
|
self,
|
|
recorder: TelemetryRecorder,
|
|
*,
|
|
scope: str,
|
|
pricing: PricingTable | None = None,
|
|
text_cap: int | None,
|
|
) -> None:
|
|
"""`text_cap` 与 `scope` 无默认值是有意的: 两者都是关键行为参数。
|
|
|
|
`text_cap` 漏传即静默改变落库正文;`scope` 漏传则三类行都失去池名
|
|
——终态失败可能根本没选出源,但 scope 始终已知,不拿 `source_name` 顶替。
|
|
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
|
|
|
同理,值域校验与**装配闸**都放在这一处: 三个 Client 全部汇流到这里,
|
|
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
|
条公共装配路(`text_cap=0` 会让每条正文只剩一个省略标记;P5 不得静默)。
|
|
"""
|
|
if text_cap is not None and text_cap <= 0:
|
|
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
|
_assert_recorder_shape(recorder)
|
|
self._recorder = recorder
|
|
self._scope = scope
|
|
self._pricing = pricing
|
|
self._text_cap = text_cap
|
|
|
|
async def emit_attempt(
|
|
self,
|
|
*,
|
|
request: ChatRequest,
|
|
source: SourceConfig,
|
|
call_id: str,
|
|
latency_ms: int,
|
|
response: LLMResponse | None,
|
|
error: PolyGatewayError | str | None,
|
|
reasoning_applies: bool,
|
|
operation: CallOperation,
|
|
class_prefixed_error: bool = False,
|
|
) -> None:
|
|
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
|
|
|
|
`reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为
|
|
`True`,embedding / OCR 路径为 `False`。它不能由 emitter 自己推断——三条路径
|
|
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源
|
|
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
|
|
档。**不设默认值**: 与 `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
|
|
当场 TypeError,好过被静默当成"没表态"。`operation` 同理且另有一层:
|
|
它只能由调用点给定,**绝不读 `exc.operation`**(后者是 HTTP 子操作)。
|
|
|
|
`error` 收**领域异常对象**而非预先 `str()` 压平的文本: 状态码/底层异常类型/
|
|
网关正文在此提取成四列(设计 §5)。取消路径仍传既有字符串 `"cancelled"`。
|
|
"""
|
|
usage = _AttemptUsage.of(response)
|
|
await self._record(
|
|
request=request,
|
|
call_id=call_id,
|
|
model=source.model,
|
|
provider=source.provider,
|
|
source_name=source.name,
|
|
response_text=usage.response_text,
|
|
thinking=usage.thinking,
|
|
prompt_tokens=usage.prompt_tokens,
|
|
completion_tokens=usage.completion_tokens,
|
|
usage_source=usage.usage_source,
|
|
latency_ms=latency_ms,
|
|
ttft_ms=usage.ttft_ms,
|
|
max_inter_token_ms=usage.max_inter_token_ms,
|
|
cache_hit=False,
|
|
errors=_error_fields(error, event_kind="attempt", class_prefixed=class_prefixed_error),
|
|
cached_prompt_tokens=usage.cached_prompt_tokens,
|
|
model_reported=usage.model_reported,
|
|
reasoning_tokens=usage.reasoning_tokens,
|
|
thinking_observation=usage.thinking_observation,
|
|
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
|
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
|
tenant_id=request.tenant_id,
|
|
meta=request.meta,
|
|
reasoning_effort=_attempt_effort(
|
|
request=request, source=source, response=response, applies=reasoning_applies
|
|
),
|
|
operation=operation,
|
|
event_kind="attempt",
|
|
attempts=None,
|
|
total_latency_ms=None,
|
|
)
|
|
|
|
async def emit_cache_hit(
|
|
self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation
|
|
) -> None:
|
|
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。
|
|
|
|
逻辑计数两列恒 NULL: 本行描述的是"一次命中",不是一次逻辑调用的终态。
|
|
"""
|
|
await self._record(
|
|
request=request,
|
|
call_id=response.call_id,
|
|
model=response.model,
|
|
provider=response.provider,
|
|
source_name=response.source_name,
|
|
response_text=response.content,
|
|
thinking=response.thinking,
|
|
prompt_tokens=response.prompt_tokens,
|
|
completion_tokens=response.completion_tokens,
|
|
usage_source=response.usage_source,
|
|
latency_ms=0,
|
|
ttft_ms=None,
|
|
max_inter_token_ms=None,
|
|
cache_hit=True,
|
|
errors=_ErrorFields(),
|
|
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
|
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
|
model_reported=response.model_reported,
|
|
reasoning_tokens=response.reasoning_tokens,
|
|
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
|
|
thinking_observation=response.thinking_observation,
|
|
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
|
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
|
sampling=canonical_sampling_json(request.sampling),
|
|
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
|
|
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
|
|
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
|
tenant_id=request.tenant_id,
|
|
meta=request.meta,
|
|
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
|
|
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
|
|
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
|
operation=operation,
|
|
event_kind="cache_hit",
|
|
attempts=None,
|
|
total_latency_ms=None,
|
|
)
|
|
|
|
async def emit_terminal_failure(
|
|
self,
|
|
*,
|
|
request: ChatRequest,
|
|
call_id: str,
|
|
error: PolyGatewayError | str,
|
|
operation: CallOperation,
|
|
stats: CallStats,
|
|
class_prefixed_error: bool = False,
|
|
) -> None:
|
|
"""一次**逻辑调用**的失败终态: 无具体源,溯源字段置空标记。
|
|
|
|
`latency_ms` 与 `total_latency_ms` 同取**同一份冻结快照**,避免双时钟微差;
|
|
故本方法不再收 `latency_ms`。token 与 cost 一律不从 attempt 行复制
|
|
(费用聚合仍只由 attempt / cache_hit 行决定,口径不变)。
|
|
"""
|
|
await self._record(
|
|
request=request,
|
|
call_id=call_id,
|
|
model="",
|
|
provider="",
|
|
source_name="",
|
|
response_text="",
|
|
thinking="",
|
|
prompt_tokens=0,
|
|
completion_tokens=0,
|
|
usage_source="unavailable",
|
|
latency_ms=stats.total_latency_ms,
|
|
ttft_ms=None,
|
|
max_inter_token_ms=None,
|
|
cache_hit=False,
|
|
errors=_error_fields(
|
|
error, event_kind="terminal_failure", class_prefixed=class_prefixed_error
|
|
),
|
|
cached_prompt_tokens=None,
|
|
model_reported=None,
|
|
reasoning_tokens=None,
|
|
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
|
|
thinking_observation=ThinkingObservation.UNKNOWN,
|
|
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
|
sampling=canonical_sampling_json(request.sampling),
|
|
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
|
tenant_id=request.tenant_id,
|
|
meta=request.meta,
|
|
# 可能根本没选出源,故与 sampling 同样只取请求档
|
|
reasoning_effort=_normalize_effort(request.reasoning_effort),
|
|
operation=operation,
|
|
event_kind="terminal_failure",
|
|
attempts=stats.attempts,
|
|
total_latency_ms=stats.total_latency_ms,
|
|
)
|
|
|
|
async def _record(
|
|
self,
|
|
*,
|
|
request: ChatRequest,
|
|
call_id: str,
|
|
model: str,
|
|
provider: str,
|
|
source_name: str,
|
|
response_text: str,
|
|
thinking: str,
|
|
prompt_tokens: int,
|
|
completion_tokens: int,
|
|
usage_source: str,
|
|
latency_ms: int,
|
|
ttft_ms: float | None,
|
|
max_inter_token_ms: float | None,
|
|
cache_hit: bool,
|
|
# 1.3.5: 错误四列已由 `_error_fields` 定型(三种入参形态 × 两类行的唯一规则所有者),
|
|
# 本方法只搬运——拆成五个平铺参数就是把"一处定型"换回"三处各自拼"
|
|
errors: _ErrorFields,
|
|
cached_prompt_tokens: int | None,
|
|
model_reported: str | None,
|
|
sampling: str | None,
|
|
reasoning_tokens: int | None,
|
|
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
|
|
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
|
|
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
|
|
thinking_observation: ThinkingObservation,
|
|
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
|
tenant_id: str | None,
|
|
meta: Mapping[str, Any],
|
|
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
|
|
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
|
|
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
|
|
reasoning_effort: str | None,
|
|
# —— 1.3.5: 行形态与逻辑调用快照 ——
|
|
operation: CallOperation,
|
|
event_kind: EventKind,
|
|
attempts: int | None,
|
|
total_latency_ms: int | None,
|
|
) -> None:
|
|
try:
|
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
|
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
|
|
if cache_hit:
|
|
cost: float | None = 0.0
|
|
elif usage_source == "unavailable":
|
|
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
|
|
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
|
cost = None
|
|
elif errors.error is None and model and self._pricing is not None:
|
|
cost = self._pricing.cost(
|
|
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
|
)
|
|
else:
|
|
cost = None
|
|
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
|
|
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
|
|
messages_json = json.dumps(
|
|
_cap_messages(digest_messages(request.messages), self._text_cap),
|
|
ensure_ascii=False,
|
|
)
|
|
context = request.call_context
|
|
await self._recorder.record_llm_call(
|
|
call_id=call_id,
|
|
parent_call_id=request.parent_call_id,
|
|
session_id=request.session_id,
|
|
model=model,
|
|
provider=provider,
|
|
source_name=source_name,
|
|
messages=messages_json,
|
|
response=_cap_text(response_text, self._text_cap),
|
|
thinking=_cap_text(thinking, self._text_cap),
|
|
prompt_tokens=prompt_tokens,
|
|
completion_tokens=completion_tokens,
|
|
usage_source=usage_source,
|
|
latency_ms=latency_ms,
|
|
ttft_ms=ttft_ms,
|
|
max_inter_token_ms=max_inter_token_ms,
|
|
cache_hit=cache_hit,
|
|
error=errors.error,
|
|
cost=cost,
|
|
cached_prompt_tokens=cached_prompt_tokens,
|
|
model_reported=model_reported,
|
|
sampling=sampling,
|
|
reasoning_tokens=reasoning_tokens,
|
|
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
|
|
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
|
tenant_id=tenant_id or "",
|
|
meta=_canonical_meta_json(meta),
|
|
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
|
|
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
|
|
# Postgres 那一路悄悄少一列数据
|
|
thinking_observation=_normalize_observation(thinking_observation),
|
|
reasoning_effort=reasoning_effort,
|
|
# —— 1.3.5 十列 ——
|
|
scope=self._scope,
|
|
# 调用点给定的公开方法四值,**绝不读 `exc.operation`**(设计 §5 I1/I2)
|
|
operation=operation,
|
|
# 上下文缺席(库内现场构造的 ChatRequest)→ NULL,**不造 ID**(I5)
|
|
logical_call_id=None if context is None else context.logical_call_id,
|
|
event_kind=event_kind,
|
|
http_status_code=errors.http_status_code,
|
|
error_type=errors.error_type,
|
|
cause_type=errors.cause_type,
|
|
error_body=errors.error_body,
|
|
attempts=attempts,
|
|
total_latency_ms=total_latency_ms,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("遥测记录失败(降级不冒泡): {}", exc)
|
|
|
|
|
|
async def emit_terminal_once(
|
|
emitter: TelemetryEmitter | None,
|
|
*,
|
|
request: ChatRequest,
|
|
context: _CallContext,
|
|
error: PolyGatewayError | str,
|
|
operation: CallOperation,
|
|
class_prefixed_error: bool = False,
|
|
) -> None:
|
|
"""三个 client 共用的**终态唯一出口**: 去重 + 同步冻结快照 + best effort 写入。
|
|
|
|
去重由 `claim_terminal()` 承担(每逻辑调用至多一条终态行);`emitter is None`
|
|
或已写过 → 直接返回。
|
|
|
|
**降级范围包含诊断字段的提取与构建**,不只是写入那一步: `_record` 内的
|
|
`except Exception` 只兜住落库,而 `_error_fields` / `canonical_sampling_json`
|
|
在它**之外**求值——下游经公共端口(自实现 `StructuredOutputStrategy` 或
|
|
transport)构造出的 `ResultInvalidError(validation_errors=(非 str,))` 会让提取期
|
|
抛 `TypeError` 顶替调用方本该收到的领域异常,错误四分类被击穿且终态行照样丢。
|
|
故在此整段兜底,与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款写法。
|
|
终态行按 best effort: 兜底命中时本次逻辑调用**没有**终态行(`claim_terminal()`
|
|
已消耗,不补写、不重试——重写一遍只会把同一个提取期异常再抛一次)。
|
|
|
|
**`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务): 这一次
|
|
`await` 本身就是新的取消点,外部取消落在它上时调用方会看到 `CancelledError`
|
|
而非领域错误——与 TelemetryMW 的历史行为同款,已经人类批准(设计 §6/§10)。
|
|
快照冻结是**同步**动作,故终态行不含它自身的写入耗时。
|
|
"""
|
|
if emitter is None or not context.claim_terminal():
|
|
return
|
|
try:
|
|
stats = context.snapshot()
|
|
await emitter.emit_terminal_failure(
|
|
request=request,
|
|
call_id=str(uuid.uuid4()),
|
|
error=error,
|
|
operation=operation,
|
|
stats=stats,
|
|
class_prefixed_error=class_prefixed_error,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("终态遥测记录失败(降级不冒泡): {}", exc)
|
|
|
|
|
|
class TelemetryMW:
|
|
"""洋葱最外层: 只观测尝试层看不见的**缓存命中**。
|
|
|
|
1.3.5 起不再在此写终态失败行: 终态由 `GatewayClient.chat` 的公开边界经
|
|
`emit_terminal_once` 统一写出。两处同时写会让同一次失败出两条终态行,
|
|
而下游正是按 `event_kind = 'terminal_failure'` 计失败调用次数的。
|
|
"""
|
|
|
|
def __init__(
|
|
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
|
|
) -> None:
|
|
self._emitter = emitter
|
|
# `now` 自 1.3.5 起本类不再读取(终态行迁到公开边界后无耗时可测),但形参保留:
|
|
# 删它会平白打断 `TelemetryMW(emitter, now=...)` 这一既有装配写法
|
|
|
|
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
|
response = await call_next(request)
|
|
if response.cache_hit:
|
|
await self._emitter.emit_cache_hit(request=request, response=response, operation="chat")
|
|
return response
|