跳转至

追踪 API

raven(以及任何其他接入方)与 raven-tracing 之间的契约。

原则:标准由 tracing 拥有,应用负责接入。 raven-tracing 定义什么是 span、 每种 span 携带哪些字段、以及如何渲染。应用(raven)通过在自己选定的位置调用一个 小而稳定的门面——trace.span(...)——来完成自我埋点。两侧都不依赖对方的内部实现, 唯一的耦合是这份 API 的版本号,加上下文的语义约定。

这与 OpenTelemetry 的模型一致(库定义 API 与数据模型,应用做手工埋点),因此同一套 纪律同样适用:API 保持微小且缓慢演进,其背后的 SDK(存储、查看器、磁盘格式)可以自由 迭代而不触碰接入方。

相关:磁盘记录的形态(audit.span.v1)定义在 raven/tracing/spans.pybuild_span), 并在 §2 中做了摘要;本文是产生这些记录的写入侧标准。


1. 公开 API

一次导入,一个主调用:

from raven_tracing import trace

with trace.span("llm.call", {"llm.provider": provider, "llm.model": model}) as s:
    resp = do_call(...)
    s.set({"llm.usage.total_tokens": resp.usage.total, "llm.finish_reason": resp.finish_reason})

trace.span(name, attributes=None, *, kind=None, **kw) -> Span

一个上下文管理器:进入时开启 span,退出时定版并记录。

  • name——点分的语义名称,<domain>.<verb>(例如 llm.call)。它决定默认的 kind, 以及查看器中的标签与渲染方式(见 §2)。
  • attributes——以全限定点分键构成的映射(标准形式,例如 {"llm.provider": p, "llm.model": m})。标准键都是点分的,所以映射是主要形式; **kw 接受裸键以便书写(原样存储,不做自动命名空间化——属性的命名空间可以与名称的 域不同,例如 session.turn 携带的是 turn.*)。
  • kind——可选,用于覆盖粗粒度分类(session|model|tool|subagent|skill|memory|plugin)。 默认由名称的域推导;自定义节点请显式传入(§3)。

嵌套通过 contextvars 自动完成:在另一个 span 活跃期间开启的 span 会成为它的子节点; 上下文可跨 await 存续,并会快照到任务上。一轮对话的根节点是 session.turn span, 其余一切都嵌套在它之下。

Span 句柄

方法 作用
s.set(attrs=None, **kw) 将属性合并到该 span(点分键映射和/或裸关键字参数)
s.artifact(key, payload, *, kind="json") 将大体积负载落盘到行外存储,并挂上 <key>.artifact_path/_sha1/_bytes 与一段截断的 preview。用于提示词、工具输入输出、召回结果。
_spans.address_items(items) 把一组消息按内容寻址存放到 audit-artifacts/_messages/ 下,返回可嵌入负载的 {"$msg": sha1} 引用。参见下文的 audit.artifact.v2
s.event(name) 追加一条时间线事件 {time, name}
s.error(exc) 标记 status = ERROR(若代码块抛出异常则自动完成)

只读属性:s.trace_ids.span_ids.name

模块级辅助函数

调用 返回
trace.enabled() 记录是否开启(来自配置或环境变量)
trace.current() 当前活跃的 TraceCtx,否则为 None
trace.use_context(ctx) 上下文管理器:重新进入由 current() 捕获的上下文,用于在调度该工作的那一轮之外开启的 span(例如由长驻工作进程消费的队列)

硬性保证(接入方为何是安全的)

  1. 关闭时为空操作。 若被配置禁用或没有活跃的 SDK 后端,trace.span(...) 返回一个 空操作句柄:没有 I/O,开销接近零,with 代码块照常执行。
  2. 绝不破坏调用方。 门面会吞掉自身的失败(属性错误、磁盘错误、SDK 缺陷)并以 debug 级别记录日志。它会原样重新抛出应用的异常(在记录 status=ERROR 之后)。追踪的缺陷 永远不会改变或中断宿主的控制流。
  3. 导入安全。 即使没有任何配置存在,导入 raven_tracing 并调用该 API 也必须成功。

@trace.instrument(...)——装饰器(接入方的主要机制)

接入方通过给方法加注解来埋点——方法体不受影响,因此这不会改变核心逻辑(只增加一层 观测包装):

@trace.instrument("llm.call", extract=semconv.llm_call)
async def chat_with_retry(self, ...): ...

trace.instrument(name, *, kind=None, seed=None, on_open=None, extract=None) 可以包装同步异步方法:

  • extract(span, bound_args, result, exc)——在 finally 中执行(出错时输入也已捕获), 负责填充最终的属性与产物。bound_args 是按名称组织的调用参数;result 是返回值 (出错时为 None);exc 是抛出的异常(成功时为 None)。标准提取器位于 raven.tracing.semconvllm_calltool_callmemory_* 等)。
  • seed(bound_args) -> dict——返回 session_key / channel / chat_id,用于开启一个 span(一轮对话),其身份会被所有子节点继承。
  • on_open(span, bound_args)——在开启之后、方法体之前执行;用于记录输入,并对进行中的 根节点调用 span.checkpoint() 以支持实时查看。

提取器会用到的额外 Span 方法:span.retype(name, kind)(一个后来发现其实是 skill.readtool.call)、span.cancel()(丢弃一个条件 span,例如 skill.inject 只在确实注入了内容时才保留)、span.checkpoint()span.elapsed_ms()。对于不应成为 活跃父节点的叶子标记,请传入 detached=True——可取消的 span 必须如此,否则在取消之前 开启的子节点会悬挂在一个未发出的 span 之下。

每一个 span 家族都以这种方式埋点——包括 subagent.run:子代理运行的是同一批被装饰的 原语(chat_with_retry / tools.execute),因此它的 span 由那些装饰器捕获,并借助 asyncio.create_task 在派生时取得的 contextvars 快照,自动嵌套在 subagent.run 节点 之下。全程没有使用任何 monkeypatch。


2. 语义约定(标准 span 类别)

kind 是单词形式的分类(决定节点配色与分组)。name<domain>.<verb> 标识符 (决定标签与渲染)。属性按域划分命名空间。接入方应当填充「必填」列;「可选」列 能带来更丰富的渲染效果。

name kind 必填 可选属性
session.turn session turn.input_previewturn.output_previewturn.in_progressturn.capabilities.{tools,plugins,skills}
llm.call model llm.providerllm.model llm.provider_classllm.finish_reasonllm.call_idllm.invocation_sourcellm.usage.{input,output,total,cache_read,cache_write}_tokensllm.usage.cost_totalllm.request_{bytes,images,image_bytes}llm.http_statusllm.served_byllm.response_id;产物 llm.inputaudit.artifact.v2:消息引用与工具,另含 request = 消息加工具的负载大小与图片数量,而非服务商最终请求体,以及 generation = 本次调用向模型提出的要求;见下文)、llm.output(另含 call = 传输记录)
tool.call tool tool.name tool.call_idtool.duration_mstool.error;产物 tool.input(参数)、tool.output(结果)
subagent.run / subagent.call subagent subagent.idsubagent.labelsubagent.tasksubagent.session_idsubagent.parent_trace_idsubagent.parent_span_idsubagent.trace_idsubagent.status
skill.read / skill.inject skill skill.nameskill.idskill.sourceskill.pathskill.scripts_dir(存在即表示物化了一个可运行的 bundle,而非仅有说明文本)、skill.read.via_tooluse_skill/read_skill/read_file)、skill.inject.{names,count,via}
memory.recall / .store / .feedback / .extract / .consolidate / .profile_refresh memory memory.scopememory.hitsmemory.message_countmemory.kindmemory.deposit_summarymemory.deposit_statusmemory.surfacememory.sections_rewritten;产物按操作类型各异
plugin.load / tracing.bootstrap plugin plugin.nameplugin.contributionplugin.id

产物格式

一个产物要么是普通的 JSON 负载(v1,没有判别字段),要么是 audit.artifact.v2——后者 携带 "artifactFormat": "audit.artifact.v2",并把每条消息替换为指向 audit-artifacts/_messages/<sha1[:2]>/<sha1>.json{"$msg": "<sha1>"} 引用。 只有 llm.input 以这种方式写入;其余类别一律保持 v1,且磁盘上已有的 v1 产物永不重写。 外壳中的 requestgeneration 原样携带:两者都不含消息内容,改为引用只会多一层 间接寻址而毫无收益。

raven/tracing/artifact_v2.py 是该格式的唯一权威——规范序列化、外壳结构、引用校验、 引用解析都在其中——因为随附的查看器用 JavaScript 镜像了同一套逻辑,并有一个跨语言的 往返测试比对两者。解析外壳的消费方必须遵守两条规则:messages 解析为消息对象列表, 而 systemPromptprompt 解析为其消息的 content 并强制转为文本。sha1 不是 40 位 小写十六进制字符的引用属于数据而非地址,原样透传;外壳也可能在无法写出 blob 时内联 携带一条原始消息。

llm.input 而言,artifact_sha1artifact_bytes 描述的是外壳,而不是它所引用的 对话;llm.request_bytes 才是请求体量的信号,它直接从消息本身测得,因而不受外壳影响。 外壳对其列出的引用求哈希,因此篡改痕迹可定位到单条消息,而不只是证明负载被改过。

有两处解析点会把外壳还原为等价的 v1:打包轨迹时的 raven/trajectory/bundle.py (使 bundle 不依赖消息存储),以及随附查看器 server.js 中的 readArtifact (使 UI 永远看不到引用)。位于二者下游的消费方——回放、录像带最小化、查看器 UI—— 读到的都是 v1 形态,无需了解 v2。

服务商标注: llm.provider 是调用路由到的逻辑后端(例如 openrouter),由模型的 网关前缀推导;llm.provider_class 是当两者不同时的具体实现类(例如 LiteLLMProvider)。 llm.served_by 是第三样东西:响应自称由谁提供服务,仅在上游明确给出时才存在——在一个 会扇出的网关背后,这是判断究竟是谁作答的唯一途径。

传输记录。 llm.outputcall 对象记录的是这次交换做了什么,而不是模型说了什么: http_statusserved_byserved_modelresponse_idheadersbody——最后一项 仅在调用什么也没返回时才填充,因此可用的回答绝不会被存两遍。它只由 raven.providers.call_record 构造,该模块会限制 body 与 headers 的体量,并替换任何以 凭据命名的 header 的值;其他任何地方都不得构造传输记录。请求自身的字节永远不会被复制进 记录:llm.inputrequest 对象只做计数(线上 bytesimages、解码后的 imageBytes),图片负载只被计数,不被记录。

命名规则: - name = <domain>.<verb>,小写点分。 - 属性键 = <domain>.<field>,与该 span 的域一致。 - kind 是封闭词表:session|model|tool|subagent|skill|memory|plugin


3. 自定义节点

任何接入方(或插件)都可以记录自定义 span,无需注册:

with trace.span("raven.sentinel.tick", {"sentinel.reason": r}, kind="plugin") as s:
    s.set({"sentinel.fired": n})

规则: - name 请使用自有命名空间raven.<subsystem>.<verb>),以免与 §2 的标准名称冲突。 - 显式传入 kind(否则会退化为通用节点类别)。 - 查看器对未知名称采用通用渲染(标题取自 name,副标题取自选定的某个属性)。若需要定制 渲染,请提供一条描述符条目(descriptors/*.json,以 name 为键)——这是查看器的 渲染标准,随附于 raven/tracing/viewer/descriptors/


4. 接入方集成契约(raven)

  1. raven-tracing 声明为默认依赖(默认开启的可选组 raven[tracing]),使其随 raven 一同分发,并能在 uv tool upgrade 后继续存在。
  2. 在埋点处 from raven_tracing import trace,用 with trace.span(...) 包裹目标操作。 埋点位于应用自身的代码中,随重构一同移动,并在 diff 中可见(没有外部 monkeypatch 会悄悄失效)。
  3. 通过 [tracing].enabled(raven 配置)或 RAVEN_TRACING=0(环境变量覆盖)来开关。 禁用时该 API 为空操作。
  4. 应用永远不导入 SDK 内部实现(存储、查看器)——只使用门面。

不存在 monkeypatch 或自动埋点路径:全部埋点都是 raven 自身源码中显式的 @trace.instrument 注解,因此它随代码移动、在 diff 中可见(不会在重构时悄悄失效)。


5. 版本与治理

  • API 与语义约定作为 standard-api.v1 一同版本化,独立于应用。
  • 新增性变更(新的可选属性、新的 span 名称或类别)→ 次版本号递增,向后兼容。
  • 破坏性变更(重命名或删除属性、改动 API 签名)→ 主版本号递增,并附迁移说明; 接入方应锁定受支持的版本范围,并在不匹配时发出警告(而不是静默降级)。
  • 一致性快照测试(冻结的 span 名称与必填字段)在 CI 中守护该契约;未同步提升版本号的 改动会让构建失败。
  • 磁盘记录格式单独版本化为 audit.span.v1(定义在 raven/tracing/spans.py); 两者各自独立演进。

现状

仓库内实现,已完整。每一个 span 家族(turn / llm / tool / memory / skill.inject / plugin.load / subagent)都通过 raven 自身方法上的 @trace.instrument 完成埋点;没有 monkeypatch,也没有 instrument.install()——自动探测模块已被移除。semconv.py 拥有 标准的属性与产物构造器。

留待独立开源阶段(P4)处理,均不阻塞仓库内使用: - 让导入变为可选:raven 核心在模块加载时硬导入 raven.tracing(装饰器在类定义时应用), 因此它必须随 raven 分发;要让追踪成为真正可选的 extra,需要先有一个空操作回退垫片。 - 把 raven 专有的提取器(semconv.py)移到 raven 一侧;独立包中只保留通用 API、schema 与查看器。 - 从独立包中去除 raven 品牌标识(FRAMEWORKRAVEN_* 环境变量、~/.raven 路径), 以及 raven.config/raven.token_wise 的软导入。 - 冻结 standard-api.v1;发布 raven-tracing;raven 默认依赖它。