首页/运维/可观测性

可观测性

这一页回答两个问题:系统现在健康吗, 以及刚才那个答案是怎么来的。前者靠健康检查与指标,后者靠轨迹。

健康检查:三态,不是两态

探活端点返回三种状态,区分「完全不可用」与「部分降级」:

状态HTTP触发条件
unavailable 503 内部存储不可达——服务真的没法工作
degraded 200 存储正常,但某个数据源连不上
ok 200 都正常

app.py:473 health app.py:53 _HEALTH_PING_TIMEOUT_S

为什么数据源挂了不返回 503

一个数据源连不上,不该让编排系统把这个实例摘掉——其他数据源还是好的,而且这个数据源可能只是临时网络抖动。

所以它降级为 200 + degraded,把判断权交给监控侧。反之存储不可达是真的没法服务,必须 503。

探活本身有 2 秒超时——健康检查不该因为下游慢而挂住,那样探活会从「发现问题」变成「制造问题」。

每个数据源还带一条 readonly

数据源条目里除了 ok,还有一个只读自检结论。它不参与 ok 与顶层 status——「连不上」和「只读边界其实不在」的修法毫无关系 (一个查连接,一个改授权),折进同一个词,监控上就再也分不出是哪一种,而只有后者是安全问题。

verifiedbasis含义
truegrants查了权限表,有正面依据表明这个账号写不了
falsegrants有证据表明写得动——硬边界不存在,启动时会 WARN
nullunverifiable这个方言没有角色概念(SQLite / DuckDB 是文件权限),查无可查
nullprobe_failed查了,没查成(权限不足 / 超时 / 连接断)
nullnot_probed启动自检没覆盖到这个源(启动后新注册的)

null 不是「安全」。把「没查成」渲染成 true,等于替一道 并不存在的边界背书——那正是这个自检要防的事。授权原文(库名、账号、表名)只进日志, 不进响应:这个端点免鉴权。

Prometheus 指标

/v1/metrics 输出 Prometheus 文本格式。它是免鉴权的, 理由是单进程 serve 的计数不敏感,而抓取端通常不便于携带用户 token。

app.py:580 metrics metrics.py:542 render_metrics

指标标签回答的问题
trove_http_requests_totalmethod, path, status接口被谁打、结果如何(path 用路由模板,不是原始 URL)
trove_http_request_duration_secondsmethod, path接口慢在哪
trove_http_requests_inflight—此刻在处理的请求数
trove_llm_calls_totalprovider, model, status模型调用次数与成败
trove_llm_call_duration_secondsprovider, model模型往返时间
trove_sql_queries_totaldatasource, status真落库的查询:哪个源、成没成
trove_sql_query_duration_secondsdatasource查询耗时分布
trove_sql_cache_hits_totaldatasource结果缓存省下了多少往返
trove_sql_budget_decisions_totaldatasource, source, verdict成本判定走了哪一档、判成什么
trove_sql_degraded_totaldatasource一点估算依据都没有就执行了(I2 的可见形态)
trove_sql_kill_totaldatasource, result超时后的主动终止:发出去了没有
trove_authz_deny_totalreason执行前授权门拒绝的原因分布(无主体 / 数据源 / 表 / 判不了)——两种处置方向相反:部署没接好,还是 grants 该加
trove_authz_table_warn_totaldatasourcewarn 期命中未声明表但放行的次数——「切 enforce 会打挂多少」的分母。与拒绝计数分家:混进去,告警率里会有一半根本没被拦的查询。表名不在标签上(基数无限),名字在审计行的 authz.table_warn
trove_masking_applied_totalfield, mode哪些字段在用哪种模式脱敏(字段名来自语义层声明,基数可控)
trove_prompt_isolation_totalchannel, var, pattern进入模型上下文之前被隔离的外部值(render 模板渲染 / tool 工具回喂 / derive 派生值出生点)——原文不进标签,原文正是要被隔离掉的东西
trove_asset_runs_totaldatasource, verdict, path已验证资产(台账)被用了多少次、什么结局、走快径还是检索
trove_asset_ledger_failures_totaldatasource, 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 只到「交给了驱动、驱动没报错」为止。

如果部署形态会把这个端口暴露到不可信网络,那就应该在反向代理层收掉这个路径—— 这是部署决策,不是代码里的默认值能替你决定的。

轨迹:两种粒度

系统同时维护两份轨迹记录,用途不同:

本地 tracerun 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 端点: 轨迹文件在进程本机,管理端没有读它的路由。

trace_cmds.py:186 trace

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;
        
图 1 · 先分现象,再决定看哪——这张图是下面表格的分支版,健康检查的两条分支是唯一需要再分叉的地方。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。
现象先看哪
服务整体不可用 /v1/health 的状态与其中的存储 ping 结果
某个数据源问不出来 health 里的数据源检查段;再跑 check_drift.py
答案内容不对 先查 query_sketch 节点的输出——输出列往往是被计划定死的,而不是生成阶段选错的
想知道某一轮为什么这么答 /trace 回放,看模型当时拿到的上下文与工具返回
准确率整体下滑 回归门对比(评测与回归门),而不是逐个案例猜
一个具体的排查经验

输出列不对时,先看计划,不要先看生成的 SQL。用户消息里那句「Query plan (follow it…)」的权重高于系统提示里的规则——生成节点的行为是「照计划执行」。

所以计划里 answer_columns 写错了,生成阶段会忠实地把错误执行出来。去改提示词是白费力气。

继续读

想了解去哪
轨迹里的每个节点是什么查询工作流
日志与审计的区别安全边界
定时任务出问题运维管理
trace 相关配置配置参考
本页源码锚点的基准提交:anchor-base: 86ca61e GitHub 仓库 文档首页