会话可观测性

三个小能力包回答"这条会话叫什么、花了多少、往外报了什么"。它们都建立在 [投影](/tether/projection.html) 与事件日志之上,不引入任何旁路状态。 对应实现:src/Tether.Session.Title/、src/Tether.Session.Stats/、src/Tether.Session.Telemetry/。

标题:日志支撑的生成

ISessionTitle 是标题的共同 service。输入选择与自动触发节奏可以替换,但 normalization、 generation、事件写入、LLM 调用、manual pin/unpin、取消和竞态收口只有这一位 owner。

三种 strategy 的差异只有两项:

Strategy id生成输入自动触发显式 RefreshAsync
session-title-llm当前全部有效 prompt不自动触发每次调用生成一次
session-title-first-prompt-llm第一条有效 prompt第一条 prompt revision 至多一次每次调用仍只取第一条
session-title-all-prompts-llm当前全部有效 prompt每个新的 prompt seq 集合至多一次每次调用取当时的全集

有效 prompt 是清理后仍有可见文本的 user/message。空会话、空白、只有控制字符或没有文本块 都不写 request,也不调用 provider。自动接线只接受真实 Agent 调用:最后一个 step boundary 必须是活动 step/start,并且最新 durable request/header 的 provider/model 必须与当前 assembled request 完全相同。调度发生在主 provider 首个 update 之前且不阻塞主 stream,所以 主调用在首 token 前失败也不会漏掉标题触发,普通辅助 LLM stream 则不会误触发。

标题来源的持久化语义如下:

来源优先级语义
manualpin用户显式命名;后续 prompt 不写 automatic request,也不调用 provider
generated当前生成共同 service 接受的最新 LLM 结果
fallback兜底第一条有效 prompt 的确定性截断文本

RefreshAsync 是 deliberate unpin:即使当前标题是 manual,它也允许显式生成,但在成功 durable commit generated set 之前,manual set 始终保持 authoritative。provider failure、取消,以及 route、 input、request durability、dispatch 或 terminal protocol failure 都向调用方传播且不追加 set;empty/ malformed result 返回 null,同样不追加 set。如果 request 已经 durable commit,它只作为失败审计保留。 只有成功 generated set 才解除 manual pin;没有 provider 时,fallback-only refresh 也只有在派生并提交非空 fallback 后才会替换 manual。RenameAsync 会取消 pending/active automatic work;即使 provider 忽略取消并晚到,generation fence 也不允许它覆盖 手工标题或更新的 generation。

Durable LLM request

辅助调用按固定顺序执行:

select exact message seqs
  → reserve generation + append session/title-llm-request
  → FlushAsync(session id)
  → InspectAsync(session id) and compare exact seq/type/JSON (non-mutating)
  → dispatch the exact recorded system/messages/route/maxTokens
  → normalize result + append generation-fenced session/title/set

没有物理 persistence 的内存组合以 AppendAsync 的逻辑提交为边界;有 persistence 时,flush 或 cold inspect 任一步失败都会阻止 provider dispatch。这里使用不修改 live session 的 InspectAsync, 不会在活动 turn 中触发 resume repair 或推进 durable seq。

session/title-llm-request 是 ignorable、log-only 事件,记录:strategy 的 titleProvider、精确 sourceMessageSeqs、辅助 route 的 provider/model、完整 model-visible system 与 messages、 maxTokens、generation、触发来源,以及 target words/CJK、输入 byte limit、timeout 与标题 byte limit。共同 service 会把 target policy 写进实际 system instruction;事件与 provider 看到的字符串 逐字相同。事件不包含 credential、Authorization、provider header 或其他 secret。

冷读、resume 和 restart 只扫描 session/title/set 与 session/title-llm-request:title、pin、最大 generation 与已消费的 automatic revision 都不依赖旁路状态。同一 strategy/source seqs 已有任意 request(包括显式 refresh 或失败请求)时,restart 不会隐式重复 automatic 调用;all-prompts 在 出现新有效 prompt 后才形成新 revision。provider failure、cancel、empty/malformed result 都保留 已提交 request,但不制造错误的 set。

实现范围由 #72:对齐 session title 策略、自动触发与 durable LLM request 约束;Web 标题编辑、跨 Session index 与在线 model catalog 不在这个能力内。

统计:一个投影单元

SessionStatsProjection 是注册进 投影注册表 的一个普通单元(键 sessionStats)。 它按 exact (turn, step) 折叠 durable chunk、assembled message、step 与 tool facts:matching step/end 计 step,同一 turn 只在第一次关闭 step 时计 turn;first-token 先留在 pending state, 只有 matching assistant/message 到达后才一起提交 LLM total、TTFT、decode 与 token sample。 cancelled/failed step 没有 assembled message 时不贡献这些时长,usage-only message 仍贡献 token。 live、cold full-log 与 checkpoint+tail 走同一 projection fold。

会话累计统计与界面上的单轮精确账单是不同读法。后者使用完整 turn 的 attempt 生命周期:重试前已报告的 usage 只计一次,缺少某次尝试、计数矛盾或缺少可证明的 uncached input 时不展示精确总量。Provider 在协议归一层记录 disjoint input;流式采样与最终消息都保留该事实、实际 cache 分量及 reasoning,未知的可选分量不补零。Anthropic 的后续 delta 只覆盖出现字段,不能丢失 message_start 的输入计数,也不能继承上次请求的状态。

遥测:脱敏、去重、排干

public interface ITelemetry
{
    TelemetryMode Mode { get; }
    bool IsExportAvailable { get; }
    ValueTask<TelemetryEmitOutcome> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken ct = default);
}

管线三段式:

  1. 模式与容量闸门。 EmitAsync 先按当前 TelemetryMode 过滤,再以非阻塞方式抢占固定容量 slot;满队列拒绝新事件并返回 QueueFull,不会让 durable append 等待或无界增长。
  2. 脱敏 waterfall。 已取得 slot 的事件经过 telemetry/redact waterfall——插件可以改写属性或返回 null 拦下。redactor 抛错时 fail closed:丢弃 envelope,只发不含原始属性的诊断,绝不回退导出未脱敏值。
  3. 去重游标、重试与卸载。 单 worker 按 cursor 序批量导出,失败后自行按有界 backoff 重试,partial acknowledgement 只释放已确认前缀。关闭先停止 admission 并给快速 exporter 一个 drain 窗口,再取消、等待 worker/current export 与 exporter disposal;忽略取消会产生 secret-free 诊断和有界失败。

Disabled 不构造 exporter。FeedbackOnly 只接纳本次 IFeedbackService.RecordAsync 已提交的反馈;普通 EmitAsync 即使填写 IsFeedback: true 也被过滤。遥测独立核对当前 feedback 服务 generation、exact 会话与 Agent owner,并在异步脱敏之后再次检查。历史事件、私建服务和同 ID 替身会话不能获得分享授权,callback 返回后的能力不能重放。

反馈只追加一条 feedback/record 事件,再尝试进入既有脱敏与导出队列。分享内容含反馈文本、来源、可选评分/消息 ID,以及会话 ID 与事件序号/时间;不是匿名化后的纯统计。匿名回执 ID 来自 IAnonymousIdentity,不会自动匿名化用户输入的文本。回执 Full 只表示本地队列已接纳,不能证明 endpoint 收到;没有 exporter、队列满、脱敏拒绝或卸载遥测时仍可保留本地反馈,回执为 FeedbackOnly。提交事件不额外承诺立即磁盘 flush。

Web 的「设置 → 通用设置 → 数据分享」分别显示插件包清单、完整会话日志、反馈与遥测的当前状态和数据范围。这是只读快照;修改组合后点击「刷新状态」。断线或读取失败会撤下旧状态,未配置 exporter 不会显示为可导出。

插件包清单、完整会话日志分享是独立的 DeepSeek 请求扩展,不能用遥测模式代替它们各自的启用状态。

生产接线已完成:telemetry-capture row 订阅 durable SessionEvent,把 turn/step 结束与 final usage 映射为版本化事实(tether.turn.end/v1 等),统一经过 mode 闸门与 telemetry/redact,capture 失败走 contained internal/error 不影响权威 append。 exporter 按 OTLP/HTTP-JSON LogsData 协议发送(resource/scope/logRecords, scopeLogs.scope、AnyValue body/attributes、十进制字符串 timeUnixNano 与合法 severityNumber),cursor dedupe、失败重试与 bounded drain 语义不变;sessionStats v2 同步从 durable chunks/final usage 折叠 llm/TTFT/decode 与 token 计数(取消 step 不计时),并挂入 projection bundles。

LLM 观测与官方 adapter

可选的 Tether.Llm.Observability 插件(#469)经 ILlmClientDecorator seam 在 provider 发布前把 ILlmClient 包装成 MEAI OpenTelemetryChatClient,activity 走 LlmTelemetryActivityBridge 进入上面的 telemetry 管线,EnableSensitiveData=false, 只挂 TelemetryMode.Full。它不拦截标准 OpenAI 的官方 adapter(MeaiOpenAiBridge, #470)——那条路径在 provider 内部完成 transport/凭据注入,观测只发生在装饰 seam, 两层的 activity/指标仍汇到同一 redact+export 出口;Disabled/FeedbackOnly 下 不装任何 decorator,也不会新增导出。

LLM Span 以 Log 事件形态进入 OTLP LogsData 导出:

  • Activity 结束时,LlmTelemetryActivityBridge 会提取语义属性(模型、提供商、token 用量、耗时及状态)转化为 tether.llm.request/v1 遥测事件。
  • 该事件经过统一的 telemetry/redact waterfall,最终注入 OTLP/HTTP-JSON LogsData exporter(作为一条 log record 导出,非 OTLP Traces 数据流)。所有可观测事件均收敛于 LogsData 单一连接与通道。
  • ILogger 通道被显式禁用(logger: null,ObservedLlmChatClient 的 GetService 不暴露 logger),确保所有观测事实只走结构化遥测管线,不产生旁路非结构化文本日志。

关闭观测的方式:

  1. 遥测模式降级:将遥测模式配置为非 Full(如 TelemetryMode.Disabled 或 TelemetryMode.FeedbackOnly),插件在非 Full 模式下会自动跳过装饰;
  2. 移除插件行:在组合配置 YAML 中直接移除 llm-observability 插件项,彻底不挂载观测中间件。

下一步

在 GitHub 上编辑此页