zcbot/core/storage/telemetry.py

165 lines
6.6 KiB
Python

"""失败埋点(telemetry)—— 与计费(usage.py)分家的另一类 usage_events 写入。
judged by kind:这里的四类 cost 恒 0、tokens 不入 kind=chat 汇总,语义是**可观测性
留痕**而非用户花销 —— 它们是 admin「工具失败聚集」面板与巡检邮件(core/toolfail.py)
的唯一持久数据源。此前与真计费(chat/image/video/vision)混住 usage.py,污染
「usage=计费」的心智模型(架构审查数据层 Top4),2026-07-23 拆出。
kind 常量是 loop/llm_transport(写)与 toolfail(读 SQL)之间的契约单一事实源 ——
以前两侧靠字符串字面量约定,改名会静默漏读。
"""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from .engine import session_scope
from .models import UsageEvent
# ── kind 契约常量(写侧本模块 / 读侧 core/toolfail.py 共用)──
KIND_TOOL_MALFORMED = "tool_malformed"
KIND_TOOL_SALVAGED = "tool_salvaged"
KIND_EMPTY_RESPONSE = "empty_response"
KIND_RUN_ERROR = "run_error"
def record_malformed_tool_call(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
tool: str,
arg_len: int,
error: str,
head: str,
tail: str,
tokens_in: int = 0,
tokens_out: int = 0,
) -> None:
"""记一次被丢弃的畸形 tool_call(kind=tool_malformed)。
这不是记账而是留痕:畸形轮整轮丢弃、messages 无痕,此行是 admin「工具失败聚集」
面板与巡检邮件唯一的数据源(core/toolfail.py 第二段扫描按 units->>'tool'/'err'
聚合)。cost_cny 恒 0 —— cost 统计全 kind 合计且随任务展示,provider 抖动的浪费
不该算成用户花销;该轮真实 token 快照进 units,将来要算浪费成本可从 units 反推
(token 汇总只算 kind=chat,此处 tokens_in/out 不会混进 token 统计)。
"""
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # 畸形轮不入 messages,无可关联
kind=KIND_TOOL_MALFORMED,
model_profile=model_profile,
units={
"tool": tool,
"len": int(arg_len),
"err": str(error)[:200],
"head": head,
"tail": tail,
"tokens_in": int(tokens_in),
"tokens_out": int(tokens_out),
},
cost_cny=Decimal("0"),
))
def record_salvaged_tool_call(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
tool: str,
arg_len: int,
salvaged_len: int,
head: str,
) -> None:
"""记一次从畸形 arguments 里抢救成功的 tool_call(kind=tool_salvaged)。
与 record_malformed_tool_call 是一对:畸形轮本会整轮丢弃 + 非流式重 roll,salvage
命中时改成就地抠出尾部完好 JSON、当轮照常执行,省掉一次重试。此行是留痕(cost 恒 0,
tokens 不入 kind=chat 汇总),供「工具失败聚集」面板 / 事后核对区分「真丢弃」与「抢救回」:
arg_len=损坏前缀+JSON 的总长,salvaged_len=抠出 JSON 的长,差值即被丢弃的 wire 垃圾前缀长。
head 存前 300 字(过 ascii 转义,消费端编码不可控)供核验前缀形态是否仍是 char-0 型。
"""
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # 抢救出的 tool_call 正常执行并入 messages,但此留痕行不与之关联
kind=KIND_TOOL_SALVAGED,
model_profile=model_profile,
units={
"tool": tool,
"len": int(arg_len),
"salvaged_len": int(salvaged_len),
"head": head,
},
cost_cny=Decimal("0"),
))
def record_empty_response(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
attempt: int,
tokens_in: int = 0,
tokens_out: int = 0,
finish_reason: str = "",
) -> None:
"""记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。
背景(task 2a1bc25d 案):unifyllm 网关对某档 Claude 偶发把 tool_use 漏成正文 / 直接吐空,
run loop 见 tool_calls 空即当「模型答完」静默 done —— 无报错、run_status=idle,只能人肉
挖 DB 才发现。此行是留痕(cost 恒 0,tokens 不入 kind=chat 汇总):即使当轮非流式重试救回,
也留一条供「工具失败聚集」面板看到「哪个模型档在吐空」(第四段扫描,tool 名固定 "(empty)")。
attempt=第几次尝试(1=首个流式轮),tokens 快照供估算浪费。单条转瞬即逝不触发面板(阈值
min_count/min_tasks),跨 task 系统性吐空才冒头 —— 正是要抓的网关级抖动。
"""
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # 空响应轮不入 messages(丢弃重试),无可关联
kind=KIND_EMPTY_RESPONSE,
model_profile=model_profile,
units={
"attempt": int(attempt),
"tokens_in": int(tokens_in),
"tokens_out": int(tokens_out),
# finish_reason 区分「网关 wire 吐空」(stop/其他)与「输出达上限被截断」
# (length)—— 后者是我方输出预算/推理失控,同上下文重试无效(见 loop 处理)。
"finish_reason": finish_reason or "",
},
cost_cny=Decimal("0"),
))
def record_run_error(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
error: str,
) -> None:
"""记一次 run 级终态错误(kind=run_error)。
留痕而非记账(cost 恒 0):run 在 LLM 请求层/构建期直接抛异常(RateLimitError
余额不足、认证失败等)时,整轮没有 tool 消息,错误只写 tasks.run_error 一列 ——
而该列只留最后一次,下次成功即被清掉。此行是「工具失败聚集」面板 + 巡检邮件
看到 run 级错误的唯一持久数据源(core/toolfail.py 第三段扫描按 units->>'err'
聚合)。task 2a1bc25d 案:Zai 余额不足连挂 3 次续跑,前端无提示、admin 无感知。
"""
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # 错误轮通常无 assistant message 落库,无可关联
kind=KIND_RUN_ERROR,
model_profile=model_profile,
units={"err": str(error)[:500]},
cost_cny=Decimal("0"),
))