feat: track logical call statistics across governed calls

This commit is contained in:
2026-09-09 10:04:39 -04:00
parent 300ced5dbd
commit 87c261bf73
13 changed files with 650 additions and 10 deletions
+2
View File
@@ -39,6 +39,7 @@ from polygateway.thinking import (
)
from polygateway.types import (
EFFORT_ORDER,
CallStats,
Effort,
EmbeddingResponse,
LLMResponse,
@@ -57,6 +58,7 @@ __all__ = [
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted",
"CallStats",
"CircuitOpenError",
"EmbeddingClient",
"EmbeddingResponse",
+12 -1
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import hashlib
import json
import random
@@ -46,6 +47,7 @@ from polygateway.types import (
Effort,
LLMResponse,
TelemetryStatus,
_CallContext,
coerce_effort,
validate_caller_dimensions,
validate_request_overlay,
@@ -289,6 +291,10 @@ class GatewayClient:
self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal)
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
self._emitter = emitter
self._transport = transport
self._telemetry = telemetry
self._cache = cache
@@ -370,6 +376,8 @@ class GatewayClient:
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
)
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now)
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -383,8 +391,11 @@ class GatewayClient:
reasoning_effort=effort,
tenant_id=dimension_tenant_id,
meta=dimensions,
call_context=context,
)
return await self._handler(request)
response = await self._handler(request)
# 快照在返回前冻结: 故它含缓存命中路径与已完成的内联遥测耗时
return dataclasses.replace(response, call_stats=context.snapshot())
async def aclose(self) -> None:
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
+15 -2
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import math
import random
import time
@@ -47,6 +48,7 @@ from polygateway.types import (
EmbeddingResponse,
LLMResponse,
TelemetryStatus,
_CallContext,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
@@ -184,7 +186,11 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now)
if not texts:
# 合法零尝试: 返回真实统计(attempts=0),且**不写任何遥测行**
# ——与 cache_hit 不同,不要按"遥测必录"推断它有台账行(设计 §3 M2)
return EmbeddingResponse(
vectors=[],
dim=0,
@@ -195,6 +201,7 @@ class EmbeddingClient:
latency_ms=0,
call_id=str(uuid.uuid4()),
source_name="",
call_stats=context.snapshot(),
)
if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
@@ -207,9 +214,11 @@ class EmbeddingClient:
parent_call_id,
dimension_tenant_id,
dimensions,
context,
)
)
return self._merge(outcomes)
# 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
@@ -220,6 +229,7 @@ class EmbeddingClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome:
fails = 0
reasons: dict[str, str] = {}
@@ -232,7 +242,7 @@ class EmbeddingClient:
continue
async with clock.attempting():
outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta, context
)
if isinstance(outcome, _BatchOutcome):
return outcome
@@ -258,10 +268,13 @@ class EmbeddingClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4())
started = self._now()
actual = 0
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
context.register_attempt()
try:
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
if self._expected_dim is not None and result.dim != self._expected_dim:
+7
View File
@@ -208,6 +208,10 @@ class CacheMW:
max_inter_token_ms=None,
call_id=str(uuid.uuid4()),
structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
# 过滤**会放行它**——不覆盖就会有 dict 冒充 `CallStats` 漏给调用方。
# 本次调用的真实统计由公开边界在返回前追加(设计 §3)
call_stats=None,
)
return LLMResponse(**fields)
except Exception as exc:
@@ -230,6 +234,9 @@ class CacheMW:
def _serialize(self, response: LLMResponse) -> str:
data = dataclasses.asdict(response)
data.pop("structured_data", None) # pydantic 实例不可 JSON 往返(设计 §2.1)
# 统计描述**本次**调用,存进去再放出来等于向下一个调用方谎称
# 它重试了 N 次;`asdict` 会把 `CallStats` 摊成 dict,故必须显式剔除
data.pop("call_stats", None)
return json.dumps(data, ensure_ascii=False)
async def _safe_get(self, key: str) -> str | None:
+5
View File
@@ -278,6 +278,11 @@ class RetryMW:
call_id = str(uuid.uuid4())
started = self._now()
actual = 0
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
if request.call_context is not None:
request.call_context.register_attempt()
try:
result = await self._transport.complete(
messages=request.messages,
+23 -5
View File
@@ -40,12 +40,14 @@ from polygateway.middleware.retry import StallClock, _failure_reason, backoff_de
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import OutcomeAwareSelector
from polygateway.types import (
CallStats,
ChatRequest,
LLMResponse,
OcrLayoutResult,
OcrTextResult,
TelemetryStatus,
Usage,
_CallContext,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
@@ -176,7 +178,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
outcome, call_stats = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
@@ -187,6 +189,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def parse_layout(
@@ -206,7 +209,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
outcome, call_stats = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
@@ -218,6 +221,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def check_health(self) -> dict[str, bool]:
@@ -242,11 +246,14 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
if not image:
raise ValueError("image 不能为空")
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now)
if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
fails = 0
@@ -260,10 +267,18 @@ class OcrClient:
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
kind,
image,
*picked,
reasons,
session_id,
parent_call_id,
tenant_id,
meta,
context,
)
if isinstance(outcome, _AttemptOutcome):
return outcome
return outcome, context.snapshot()
fails += 1
if fails >= self._retry.max_attempts:
raise AllSourcesExhausted(
@@ -287,11 +302,14 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt()
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))
+107 -2
View File
@@ -8,11 +8,12 @@ import dataclasses
import json
import math
import re
from collections.abc import Mapping
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType
from typing import Any
from typing import Any, Literal
from loguru import logger
@@ -274,6 +275,91 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。
终态行与 attempt 行**不是重复事实**(前者描述逻辑终态,后者描述单次尝试),
故禁止按 `error IS NOT NULL` 跨两类直接计失败调用次数(设计 §6/§8)。"""
EVENT_KINDS: tuple[EventKind, ...] = ("attempt", "cache_hit", "terminal_failure")
@dataclass(frozen=True)
class CallStats:
"""一次**公开调用**(而非单次尝试)的统计快照(设计 §3)。
四种响应各平铺三字段会立刻漂移,故收敛成单一对象并由包根导出。
第三方合成响应的 `None` 表示**未知**,不得伪造 0。
"""
logical_call_id: str
"""每次公开调用一个 UUID;重试、结构化重问、embedding 分批共享同一个。
不占用既有 `parent_call_id`(后者是调用方的业务关联,语义不变)。"""
attempts: int
"""准入后实际调用 transport 端口的次数;含免预算 429 与端口本地拒绝。
**不是 HTTP 请求条数**: OCR layout 的 POST + ZIP GET 在同一次 transport
调用内,计 1 次。缓存命中与空输入是合法的零尝试。"""
total_latency_ms: int
"""从输入校验通过到返回/异常传播前的单调时钟快照。
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
class _CallContext:
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
**每调用一个实例**的单任务对象: chat 重试、结构化重问、embedding 分批
都在同一任务内串行推进,故计数无需锁。**严禁提升为 client 实例属性**
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"
VT `evolve_llm = llm` 教训的同一形态)。
"""
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
def __init__(self, *, now: Callable[[], float]) -> None:
self.logical_call_id = str(uuid.uuid4())
self._now = now
self._started = now()
self._attempts = 0
self._terminal_claimed = False
def register_attempt(self) -> None:
"""transport 调用**前**登记一次尝试(含免预算 429 与端口本地拒绝)。
登记点在调用前而非成功后: 否则失败与取消的尝试会从计数里消失,
而那正是诊断时最需要看见的那几次。
"""
self._attempts += 1
def snapshot(self) -> CallStats:
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
return CallStats(
logical_call_id=self.logical_call_id,
attempts=self._attempts,
total_latency_ms=int((self._now() - self._started) * 1000),
)
def claim_terminal(self) -> bool:
"""首次 `True`、其后 `False`: 保证每逻辑调用至多写一条终态行。"""
if self._terminal_claimed:
return False
self._terminal_claimed = True
return True
@dataclass(frozen=True)
class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -336,6 +422,9 @@ class LLMResponse:
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档——"没看见"不许说成
"发生了"(同 `thinking_observation` 的 `UNKNOWN` 一脉)。"""
call_stats: CallStats | None = None
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
@dataclass(frozen=True)
class ChatRequest:
@@ -382,6 +471,16 @@ class ChatRequest:
而档位要经能力表校验、要进缓存 key、要落遥测——混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
# —— 库内部逻辑调用上下文(1.3.5;追加在末尾,不扰动既有字段的位置构造)——
call_context: _CallContext | None = field(default=None, compare=False, repr=False)
"""库内部逻辑调用上下文;`None` = 库内现场构造的请求,遥测 `logical_call_id` 落 NULL。
`compare=False, repr=False` 不是洁癖: 进 `compare` 会让两个内容相同的请求因
"不是同一次调用"而不相等,进 `repr` 则把库内部件泄进调用方的日志。
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
@dataclass(frozen=True)
class Usage:
@@ -722,6 +821,8 @@ class OcrTextResult:
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
@@ -739,6 +840,8 @@ class OcrLayoutResult:
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
@@ -783,3 +886,5 @@ class EmbeddingResponse:
call_id: str
source_name: str
cost: float | None = None
call_stats: CallStats | None = None
"""本次逻辑调用(含全部分批)的统计快照(1.3.5);`None` = 未知。"""