健康检查:三态,不是两态
探活端点返回三种状态,区分「完全不可用」与「部分降级」:
| 状态 | HTTP | 触发条件 |
|---|---|---|
| unavailable | 503 | 内部存储不可达——服务真的没法工作 |
| degraded | 200 | 存储正常,但某个数据源连不上 |
| ok | 200 | 都正常 |
app.py:473 health app.py:53 _HEALTH_PING_TIMEOUT_S
一个数据源连不上,不该让编排系统把这个实例摘掉——其他数据源还是好的,而且这个数据源可能只是临时网络抖动。
所以它降级为 200 + degraded,把判断权交给监控侧。反之存储不可达是真的没法服务,必须 503。
探活本身有 2 秒超时——健康检查不该因为下游慢而挂住,那样探活会从「发现问题」变成「制造问题」。
每个数据源还带一条 readonly
数据源条目里除了 ok,还有一个只读自检结论。它不参与
ok 与顶层 status——「连不上」和「只读边界其实不在」的修法毫无关系
(一个查连接,一个改授权),折进同一个词,监控上就再也分不出是哪一种,而只有后者是安全问题。
verified | basis | 含义 |
|---|---|---|
| true | grants | 查了权限表,有正面依据表明这个账号写不了 |
| false | grants | 有证据表明写得动——硬边界不存在,启动时会 WARN |
| null | unverifiable | 这个方言没有角色概念(SQLite / DuckDB 是文件权限),查无可查 |
| null | probe_failed | 查了,没查成(权限不足 / 超时 / 连接断) |
| null | not_probed | 启动自检没覆盖到这个源(启动后新注册的) |
null 不是「安全」。把「没查成」渲染成 true,等于替一道
并不存在的边界背书——那正是这个自检要防的事。授权原文(库名、账号、表名)只进日志,
不进响应:这个端点免鉴权。
Prometheus 指标
/v1/metrics 输出 Prometheus 文本格式。它是免鉴权的,
理由是单进程 serve 的计数不敏感,而抓取端通常不便于携带用户 token。
app.py:580 metrics metrics.py:542 render_metrics
| 指标 | 标签 | 回答的问题 |
|---|---|---|
| trove_http_requests_total | method, path, status | 接口被谁打、结果如何(path 用路由模板,不是原始 URL) |
| trove_http_request_duration_seconds | method, path | 接口慢在哪 |
| trove_http_requests_inflight | — | 此刻在处理的请求数 |
| trove_llm_calls_total | provider, model, status | 模型调用次数与成败 |
| trove_llm_call_duration_seconds | provider, model | 模型往返时间 |
| trove_sql_queries_total | datasource, status | 真落库的查询:哪个源、成没成 |
| trove_sql_query_duration_seconds | datasource | 查询耗时分布 |
| trove_sql_cache_hits_total | datasource | 结果缓存省下了多少往返 |
| trove_sql_budget_decisions_total | datasource, source, verdict | 成本判定走了哪一档、判成什么 |
| trove_sql_degraded_total | datasource | 一点估算依据都没有就执行了(I2 的可见形态) |
| trove_sql_kill_total | datasource, result | 超时后的主动终止:发出去了没有 |
| trove_authz_deny_total | reason | 执行前授权门拒绝的原因分布(无主体 / 数据源 / 表 / 判不了)——两种处置方向相反:部署没接好,还是 grants 该加 |
| trove_authz_table_warn_total | datasource | warn 期命中未声明表但放行的次数——「切 enforce 会打挂多少」的分母。与拒绝计数分家:混进去,告警率里会有一半根本没被拦的查询。表名不在标签上(基数无限),名字在审计行的 authz.table_warn |
| trove_masking_applied_total | field, mode | 哪些字段在用哪种模式脱敏(字段名来自语义层声明,基数可控) |
| trove_prompt_isolation_total | channel, var, pattern | 进入模型上下文之前被隔离的外部值(render 模板渲染 / tool 工具回喂 / derive 派生值出生点)——原文不进标签,原文正是要被隔离掉的东西 |
| trove_asset_runs_total | datasource, verdict, path | 已验证资产(台账)被用了多少次、什么结局、走快径还是检索 |
| trove_asset_ledger_failures_total | datasource, reason | 台账落账/读取失败——统计失败按设计不打断查询,所以这是它唯一的可见面;reason 分 store(存储)与 invalid(调用方传了域外的值),处置方向相反 |
资产那两条的标签都是闭集(verdict ∈
OK / EMPTY / ERROR,path ∈
fast_path / retrieval):问句与资产键不进标签——
同一句问句的措辞变体是无限的,进去就是把监控系统自己拖垮。要按资产看细节,
去看台账本体。隔离那条同理:变量名基数可控(模板参数名 / 工具名),
而外部值的原文一律不记。
budget_decisions 的 source 是 explain /
metadata / conservative,verdict 是
allow / degrade / reject。
不记在预算服务里:同一套估算在「生成前问价」(describe_cost)时也会跑一遍,
在服务层记会把问价的和真跑了的混成一个数——问价不碰数据库,
真跑了的那条可能打爆生产库,两者的运维含义相反。
degraded 单开一个而不从 verdict=degrade 推:后者混着两种成因——
一点依据都没有(这个计数器数的就是它),以及画像档估出这是张大表(降级恰恰是因为
有依据)。混在一起会把「有依据、按大表处置」记成「没有护栏」。
kill_total 回答的是「终止指令发出去了没有」,不是「查询停下来了没有」:
kill_sent 只到「交给了驱动、驱动没报错」为止。
如果部署形态会把这个端口暴露到不可信网络,那就应该在反向代理层收掉这个路径—— 这是部署决策,不是代码里的默认值能替你决定的。
轨迹:两种粒度
系统同时维护两份轨迹记录,用途不同:
| 本地 trace | run log | |
|---|---|---|
| 位置 | {home}/traces.jsonl | {home}/runs/{run_id}.log |
| 粒度 | 结构化事件(run / span_start / span_end / llm / tool / step / finish) |
人类可读的详细过程 |
| 保留 | 最新 2000 行(超出丢最旧) | 最新 50 个 run |
| 用途 | 批量分析、程序消费、/trace 回放(读的就是这一份) |
直接读 / grep,排查具体一轮 |
本地 trace 里的节点 span(span_start / span_end)把一次节点执行
框起来,llm 与 tool 事件靠 parent_id 挂到所属节点下——
所以 /trace 渲染出来的是一棵树(OTel trace 的形状),不是一列平铺的事件。
step 是旧式事件,仍然照写,为的是早先录下的轨迹还能回放。
local.py:24 MAX_LINES runlog.py:31 MAX_RUN_LOGS runlog.py:321 span_start
两者都有界——这是刻意的。一个把磁盘写满的可观测性系统, 最终会变成一个可用性事故。丢最旧的是最简单的有界策略。
run id 被注入到日志上下文里,所以每行日志都带 [req=... run=...],
可以直接 grep 出一轮请求的全部日志。results.jsonl 里的每条判定也带
run_id,从「哪道题错了」到「那一轮发生了什么」是一条可走的路径。
runlog.py:51 run_id_var logging.py:40 setLevel
回放一轮
/trace # 回放最近一轮的完整轨迹
/trace list # 列出可回放的轮次
这是 REPL 的斜杠命令(在 trove 交互会话里敲),不是 HTTP 端点:
轨迹文件在进程本机,管理端没有读它的路由。
Langfuse:可选的上报
配置了凭证时,同一批事件也会发往 Langfuse。启用条件是
两个环境变量同时存在;配置里的
observability.tracing.enabled 不参与「开」,只负责
撤回:设为 false 时,凭证齐全也不上报。
缺省是不撤回——少写一个 observability 段不该等于静默停录。
observability.py:63 langfuse_enabled observability.py:54 langfuse_trace_id
trace id 由 run id 推导(去掉横杠),所以两套系统里的标识是同一个—— 「本地日志里这一轮」与「Langfuse 里这一轮」不用做映射。
凭证缺失时是静默不启用,不是启动失败。理由是一致的: 追踪是增强能力,不该成为运行的前提。测试环境里相关环境变量会被主动清掉, 保证测试不试图外连。
事件从哪来:观察者中间件
追踪不在每个 LLM 调用点埋,而在 agent 循环的观察者中间件里。 默认有一个恒在的观察者,负责把事件分发到 run log 与 Langfuse。
agent_loop.py:289 _observers agent_loop.py:352 add_observer agent_loop.py:419 _trace_observer
这样做的直接好处是:新增一个可观测性后端不需要改任何调用点, 注册一个观察者即可。间接好处是「漏埋点」这类问题从根上消失了。
出问题时的排查顺序
flowchart TD
Q["出了什么问题?"]:::entry --> P1["服务整体不可用"]:::entry
Q --> P2["某个数据源问不出来"]:::entry
Q --> P3["答案内容不对"]:::entry
Q --> P4["想知道某一轮为什么这么答"]:::entry
Q --> P5["准确率整体下滑"]:::entry
P1 --> H1["/v1/health:顶层 status + 存储 ping"]:::exec
H1 --> H1a["unavailable 503:内部存储不可达"]:::err
H1 --> H1b["degraded 200:存储正常,是某个源连不上"]:::err
P2 --> H2["health 的数据源段 → check_drift.py"]:::exec
P3 --> H3["先查 query_sketch 的计划,而不是生成的 SQL"]:::exec
P4 --> H4["/trace 回放:当时的上下文与工具返回"]:::exec
P5 --> H5["回归门对比,而不是逐个案例猜"]:::gate
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
classDef err fill:#dc262614,stroke:#dc262699,stroke-width:1.5px;
| 现象 | 先看哪 |
|---|---|
| 服务整体不可用 | /v1/health 的状态与其中的存储 ping 结果 |
| 某个数据源问不出来 | health 里的数据源检查段;再跑 check_drift.py |
| 答案内容不对 | 先查 query_sketch 节点的输出——输出列往往是被计划定死的,而不是生成阶段选错的 |
| 想知道某一轮为什么这么答 | /trace 回放,看模型当时拿到的上下文与工具返回 |
| 准确率整体下滑 | 回归门对比(评测与回归门),而不是逐个案例猜 |
输出列不对时,先看计划,不要先看生成的 SQL。用户消息里那句「Query plan (follow it…)」的权重高于系统提示里的规则——生成节点的行为是「照计划执行」。
所以计划里 answer_columns 写错了,生成阶段会忠实地把错误执行出来。去改提示词是白费力气。