feat: track logical call statistics across governed calls
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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、遥测、缓存、限流/熔断后端。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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` = 未知。"""
|
||||
|
||||
Reference in New Issue
Block a user