会话可观测性
三个小能力包回答"这条会话叫什么、花了多少、往外报了什么"。它们都建立在
[投影](/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 则不会误触发。
标题来源的持久化语义如下:
| 来源 | 优先级 | 语义 |
|---|---|---|
manual | pin | 用户显式命名;后续 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);
}
管线三段式:
- 模式与容量闸门。
EmitAsync先按当前TelemetryMode过滤,再以非阻塞方式抢占固定容量 slot;满队列拒绝新事件并返回QueueFull,不会让 durable append 等待或无界增长。 - 脱敏 waterfall。 已取得 slot 的事件经过
telemetry/redactwaterfall——插件可以改写属性或返回null拦下。redactor 抛错时 fail closed:丢弃 envelope,只发不含原始属性的诊断,绝不回退导出未脱敏值。 - 去重游标、重试与卸载。 单 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/redactwaterfall,最终注入 OTLP/HTTP-JSON LogsData exporter(作为一条 log record 导出,非 OTLP Traces 数据流)。所有可观测事件均收敛于 LogsData 单一连接与通道。 ILogger通道被显式禁用(logger: null,ObservedLlmChatClient的GetService不暴露 logger),确保所有观测事实只走结构化遥测管线,不产生旁路非结构化文本日志。
关闭观测的方式:
- 遥测模式降级:将遥测模式配置为非
Full(如TelemetryMode.Disabled或TelemetryMode.FeedbackOnly),插件在非 Full 模式下会自动跳过装饰; - 移除插件行:在组合配置 YAML 中直接移除
llm-observability插件项,彻底不挂载观测中间件。