数据截至 (上游 commit 35efe178d76b)
可观测性、护栏与 VoltOps:生产化那一层
30 秒导读: 前几章讲的是 agent「怎么跑」。这一章讲的是让它「敢上线」的横切能力:每次 运行都被记成一棵 OpenTelemetry span 树,同一份数据同时推给实时 Console、写进本地存储、批量导到 VoltOps 云端;护栏(guardrail)在输入进模型前、输出出模型后各拦一道做校验/改写/拦截;跑完还能 挂人类反馈和自动打分(eval);而 VoltOps 反过来当控制面——远程托管 prompt、收集 trace 与评分。
本章聚焦 @voltagent/core 里「生产化」这一层,和 agent 主流程相对独立。想先搞懂 agent 一次生成
怎么跑,请看 01-agent-runtime.md;工具/记忆/子代理/工作流分别在
02/03/04/05。
1. 这是什么(零基础也能懂)
一句话定义: 这是 VoltAgent 里「运维视角」的一层——让你看得见 agent 在干什么、拦得住它不该 说的话、事后还能评估它说得好不好。
它解决什么问题。 一个能在本地 demo 里跑通的 agent,离「能放心接生产流量」还差三样东西:
| 缺口 | 生产上会出的事 | 本章对应能力 |
|---|---|---|
| 看不见 | 线上出错/变慢/烧钱,没有 trace 完全没法排查 | 可观测性(OpenTelemetry span) |
| 拦不住 | 用户塞脏话/PII 进来,模型把敏感信息吐出去 | 护栏(input/output guardrail) |
| 不知好坏 | 上线后质量悄悄退化,没人打分没人反馈 | 反馈(feedback)+ 评估(eval) |
再加一个「运营」缺口:prompt 硬编码在代码里,改一句话要重新发版——VoltOps 控制面把 prompt 搬到 云端托管,顺便当上面三样数据的收集端。
用起来什么样。 对使用者,这一层大多是「声明式配置」,平时感知不到它在后台干活:
// 示意,非源码:把生产化能力挂到一个 agent 上
import { Agent } from "@voltagent/core";
import { VoltOpsClient } from "@voltagent/core";
const agent = new Agent({
name: "support",
model: openai("gpt-4o"),
instructions: "You are a support agent",
// 护栏:进出模型各拦一道
inputGuardrails: [blockProfanityInput()],
outputGuardrails: [redactPII()],
// 控制面:远程 prompt + trace/评分回传
voltOpsClient: new VoltOpsClient({
publicKey: process.env.VOLTOPS_PUBLIC_KEY,
secretKey: process.env.VOLTOPS_SECRET_KEY,
}),
});
// 之后正常调用即可;span、护栏、导出全在后台自动发生
const res = await agent.generateText("How do I reset my password?");
一句话直觉。 把这层想成给 agent 装的「行车记录仪 + 安全带 + 年检」:记录仪(可观测性)全程录像、 安全带(护栏)出事时拉住你、年检(eval)定期打分。VoltOps 则是把录像和体检报告上传的云盘。
本节不出现底层细节。下面从全景开始,一层层往下钻。
2. 顶层全景(它大概怎么转)
这一层由四块拼成,彼此松耦合,可单独启用:
| 部件 | 干什么 | 核心文件 |
|---|---|---|
| 可观测性 | 把每次运行记成 OTel span 树,多路分发 | observability/index.ts、observability/node/volt-agent-observability.ts |
| 护栏 | 输入进模型前、输出出模型后校验/改写/拦截 | agent/guardrail.ts、agent/streaming/output-guardrail-stream-runner.ts |
| 反馈与评估 | 挂人类反馈、异步跑打分器 | agent/feedback.ts、agent/eval.ts |
| VoltOps 控制面 | 远程 prompt 托管 + trace/评分回传 | voltops/client.ts、voltops/prompt-manager.ts |
一次生产化运行,数据怎么流(从左到右):
用户输入
│
▼
┌──────────────┐ 拦/改/放行
│ 输入护栏 │◄── runInputGuardrails (guardrail.ts:287)
└─ ─────┬───────┘
▼
┌──────────────┐ prompt 从哪来?本地>agent>global>fallback
│ VoltOps │◄── createPromptHelperWithFallback (client.ts:1038)
│ 取 prompt │
└──────┬───────┘
▼
┌──────────────┐ 每次调用 = 一个 llm:* span
│ LLM 调用 │──► createLLMSpan (agent.ts:4445)
└──────┬───────┘ 记 token/cost:recordLLMUsage / recordProviderCost
▼
┌──────────────┐ 拦/改/放行(流式则边流边拦)
│ 输出护栏 │◄── runOutputGuardrails (guardrail.ts:435)
└──────┬───────┘
▼
┌──────────────┐ 异步打分,不阻塞返回
│ 反馈 / 评估 │──► enqueueEvalScoring (eval.ts:338)
└──────┬───────┘
▼
返回结果
上面每一步都在往同一棵 span 树里挂子 span;span 树经四个 processor 分发:
├─► WebSocketSpanProcessor → 实时推给 Console UI
├─► LocalStorageSpanProcessor → 存本地(崩溃也留证据)
├─► LazyRemoteExportProcessor → 批量 OTLP 导到 VoltOps 云端
└─► SpanFilterProcessor(包在外层,滤掉无关 instrumentation 的 span)
怎么读这张图: 竖着看是「一次运行」的先后经过(护栏→prompt→LLM→护栏→评估);横着看,每一步 都往同一棵 OpenTelemetry span 树里挂节点,树再被下方四个 processor 各自消费一遍。可观测性是 「贯穿全程的暗线」,其它三块是「特定时点的拦截器」。
下面逐块讲。
3. 可观测性:同一棵 span 树,多路分发
3.1 思路:一次构建,多处消费
VoltAgent 没有自己发明 tracing,而是直接建在 OpenTelemetry(OTel,业界标准的分布式追踪协议) 之上。好处是:你的 agent trace 能和 HTTP、数据库等其它 OTel instrumentation 拼进同一棵树。
关键设计:span 只产生一遍,分发给多个「处理器」各干各的。OTel 的 SpanProcessor 是一个只有
onStart/onEnd/forceFlush/shutdown 的接口——span 开始和结束时会挨个通知所有已注册的 processor。
VoltAgent 就靠往这个列表里塞不同 processor,实现「同一份数据同时喂 UI、存盘、上云」。
入口是 createVoltAgentObservability(observability/index.ts:23),它做一件事:按运行环境挑实现。
createVoltAgentObservability(config)
│
├─ isServerlessRuntime()? ── 是 ─► ServerlessVoltAgentObservability
│ (fetch 导出 + waitUntil,不留后台定时器)
│
└────────────────────── 否 ─► NodeVoltAgentObservability
(NodeTracerProvider + 四个 processor)
Node 与 Serverless 两实现的差异,是这一层最重要的工程取舍:
| 维度 | Node(node/volt-agent-observability.ts) | Serverless(serverless/volt-agent-observability.ts) |
|---|---|---|
| Provider | NodeTracerProvider | BasicTracerProvider(无 Node 专属 API) |
| 远程导出 | LazyRemoteExportProcessor + BatchSpanProcessor | FetchTraceExporter(用 fetch,兼容 Workers) |
| 何时 flush | 长驻进程,后台定时批量导 | 请求结束前必须 flush,靠 flushOnFinish() |
| 关键约束 | 可以有后台定时器 | 函数一返回就冻结,得用 waitUntil 把导出挂到平台后台 |
Serverless 的 flushOnFinish()(serverless/volt-agent-observability.ts:415)会优先用宿主平台
(Cloudflare/Vercel)注入的 ___voltagent_wait_until:能拿到就把 flush 挂后台、不阻塞响应;拿不到
就退化成阻塞 flush,保证 span 不丢。
3.2 四个 SpanProcessor:各消费一遍同一棵树
Node 实现的 setupProcessors()(node/volt-agent-observability.ts:124)一次性装好这四个:
| Processor | 职责 | 关键实现 |
|---|---|---|
WebSocketSpanProcessor | span 开始/结束都广播成事件,喂 Console 实时 UI | websocket-span-processor.ts:57(onStart)、:105(onEnd) |
LocalStorageSpanProcessor | 把 span 写进存储适配器,崩溃也留证据 | local-storage-span-processor.ts:17 |
LazyRemoteExportProcessor | 延迟初始化,等 VoltOpsClient 就绪再批量 OTLP 导云端 | lazy-remote-export-processor.ts:126(tryInitialize) |
SpanFilterProcessor | 包在前三者外层,滤掉非 VoltAgent 的无关 span | span-filter-processor.ts:63(shouldProcess) |
WebSocketSpanProcessor 的巧处:单例广播。 它内部拿的是一个单例 WebSocketEventEmitter
(websocket-span-processor.ts:25),onStart/onEnd 都只是把 span 转成一个轻量对象再
emit。谁想收(比如 Console 的 WebSocket server)就 subscribe。processor 自己不持有连接,
广播和消费彻底解耦。
LocalStorageSpanProcessor 的巧处:先存后补。 onStart 时就把一个「status=UNSET、无 endTime」
的半成品 span 写进存储(:49),onEnd 再 updateSpan 补全(:67)。这样即便进程中途崩了,
存储里也留着「开始了但没结束」的 span——排查线上挂起/超时特别有用。
LazyRemoteExportProcessor 的巧处:解决初始化竞态。 VoltOpsClient(带 API key)可能比
observability 晚构建。这个 processor 不在构造时连云端,而是每 100ms 轮询一次全局注册表
(startInitializationCheck,:101),等 getGlobalVoltOpsClient() 就绪再真正建
BatchSpanProcessor;在此之前 onEnd 的 span 先攒进 pendingSpans 缓冲(上限 1000,超了丢最老的,
:65),就绪后一次性回放。轮询 5 秒(50 次)还没等到就放弃。
SpanFilterProcessor 的巧处:装饰器隔离。 它是个「包装」processor——applySpanFilter
(node/volt-agent-observability.ts:169)把前面三个各包一层,只有
instrumentationScope 或 service.name 命中白名单的 span 才转发给被包的 processor
(shouldProcess,span-filter-processor.ts:63)。这样即使宿主 app 里还跑着别的 OTel
instrumentation,VoltAgent 的管线也不会把它们的 span 误当自己的处理。
采样也在这里挂:voltOpsSync.sampling.strategy 为 never 时干脆不建远程导出;为 ratio 时给
LazyRemoteExportProcessor 再套一层 SamplingWrapperProcessor(:143-146)。
3.3 把每次 LLM 调用记成 span
这是可观测性里最「有料」的部分——token 用量、成本、模型参数,全靠这里往 span 上挂属性。
Agent 每次真正调模型前,都先建一个 span。核心方法 createLLMSpan(agent/agent.ts:4477):
// 真实调用点之一,agent.ts:2021(streamText 路径)
const llmSpan = this.createLLMSpan(oc, {
operation: "streamText",
modelName: resolvedModelName,
isStreaming: true,
messages, tools, providerOptions,
callOptions: { temperature, maxOutputTokens, topP, maxRetries, attempt, modelId },
});
const finalizeLLMSpan = this.createLLMSpanFinalizer(llmSpan);
span 的名字是 llm:${operation}(如 llm:streamText),kind 为 CLIENT,挂在当前运行的 span 树下
(createChildSpan,:4469)。属性由 buildLLMSpanAttributes(:4510)组装,分三类:
| 属性类 | 例子 | 说明 |
|---|---|---|
| 模型与参数 | llm.model、llm.temperature、llm.max_output_tokens、llm.top_p | 从 callOptions 提取,只收有限数字 |
| provider 推断 | llm.provider | 模型名含 / 时取斜杠前段(如 openrouter/...) |
| 上下文快照 | llm.messages.count、llm.messages(只留最后 10 条) | 避免超长 prompt 撑爆属性 |
收尾时才记用量与成本。 createLLMSpanFinalizer(:4477)返回一个幂等的收尾函数(内部
ended 标志防重复 end),模型返回后调用它,把 usage / cost / finishReason 一次性落到 span 上:
recordLLMUsage(:4595):归一化后写llm.usage.prompt_tokens/completion_tokens/total_tokens;cached_tokens、reasoning_tokens只在 >0 时才写(省噪声)。recordProviderCost(:4627):从providerMetadata里抽 OpenRouter 的成本明细,写usage.cost、usage.is_byok、usage.cost_details.*——真实美元成本直接进 trace,能按 trace 算钱。
一次 LLM 调用的 span 生命周期:
createLLMSpan ──► buildLLMSpanAttributes (模型/参数/messages 快照)
│
▼ [模型真正执行]
│
finalizeLLMSpan ──┬─► recordLLMUsage (prompt/completion/total/cached/reasoning tokens)
├─► recordProviderCost (usage.cost 等,OpenRouter)
└─► span.setAttribute("llm.finish_reason", ...) + span.end()
4. 护栏:进出模型时拦一道
4.1 直觉:两个卡点,三种动作
护栏(guardrail)的模型很简单——在两个卡点各放一队校验器:
- 输入护栏:用户输入进模型之前跑。典型用途:拦脏话、拦 prompt 注入、脱敏。
- 输出护栏:模型产出之后跑。典型用途:PII 脱敏、超长截断、拦不当内容。
每个护栏返回一个决策,决定这条数据的命运,共三种动作:
| action | 含义 | 后果 |
|---|---|---|
allow | 放行 | 继续下一个护栏 |
block | 拦截 | 抛 GUARDRAIL_*_BLOCKED 错误,整次运行终止 |
modify | 改写 | 用 modifiedInput/modifiedOutput 替换后继续 |
创建护栏用两个工厂:createInputGuardrail(guardrail.ts:69)、createOutputGuardrail(:83)。
框架也内置了一批开箱即用的(脏话/邮箱/电话/PII/超长),在 agent/guardrails/defaults.ts。护栏挂到
agent 上是声明式的(agent.ts:1103-1104,inputGuardrails/outputGuardrails 经
normalizeInputGuardrailList/normalizeOutputGuardrailList 归一化)。
4.2 输入护栏:顺序管道,可改写可拦截
runInputGuardrails(guardrail.ts:287)把护栏排成顺序管道:一个的改写结果喂给下一个。
每个护栏都开一个 guardrail.input.* 子 span,把决策记进 trace。
核心流程(简化):
// 示意,非源码:输入护栏管道的骨架
let currentInput = input;
for (const guardrail of guardrails) {
const span = createChildSpan(`guardrail.input.${id}`, "guardrail");
const decision = await guardrail.handler({ input: currentInput, ... });
if (!decision.pass || decision.action === "block") {
span.setStatus(ERROR); // 记成失败 span
oc.traceContext.end("error", err); // 终止整棵 trace
throw guardrailError; // 抛 GUARDRAIL_INPUT_BLOCKED
}
if (decision.action === "modify") {
currentInput = decision.modifiedInput; // 改写,喂给下一个护栏
}
span.end();
}
return currentInput;
真实实现里额外处理了并行执行限制:并行模式下护栏返回 modify 会直接报
GUARDRAIL_INPUT_MODIFY_UNSUPPORTED(guardrail.ts:381)——并行护栏只能 allow/block,不能改写
(否则多个改写无法确定先后)。管道跑完若输入被改过,会 traceContext.setInput(currentInput)
把最终输入同步进 trace(:417)。
4.3 输出护栏:同一套逻辑,外加流式桥接
非流式输出护栏走 runOutputGuardrails(guardrail.ts:435),逻辑和输入侧对称:顺序管道、
三种动作、逐个开 guardrail.output.* span。差别在它多了一个「和流式护栏共享 span」的机制——
从 oc.context 里取 STREAM_GUARDRAIL_SPANS_KEY 存的 span(:452),流式阶段已经开了 span 就复用,
避免同一个护栏被记两遍。
4.4 流式护栏:边流边拦,holdUntilPass
难点:流式输出是一段段吐的,怎么在「还没吐完」时就拦? VoltAgent 的做法是给每个输出护栏一个
可选的 streamHandler,由 OutputGuardrailStreamRunner(output-guardrail-stream-runner.ts:55)
逐块喂:
模型流式吐字 (text-delta, ...)
│ 每来一个 part
▼
┌────────────────────────────┐
│ OutputGuardrailStreamRunner│ processPart(part)
│ 对每个护栏依次: │
│ handler({ part, state, │ ← state 跨块累积(如"已见多少数 字")
│ abort }) │
│ ├─ 返回改写后的 part │ → 脱敏后的 part 继续往下游
│ ├─ 返回 null │ → 丢弃这一块(过滤)
│ └─ 调 abort(reason) │ → 整条流中止
└────────────────────────────┘
│
▼ 下游拿到的是"净化过"的流
每个护栏在流开始时就建好自己的 span 并塞进 STREAM_GUARDRAIL_SPANS_KEY 映射
(registerStreamSpan,:366),这样流结束后 runOutputGuardrails 做最终校验时能复用同一个 span。
关键设计:两种流式策略。 输入护栏归一化时会带一个 streamPolicy,默认 holdUntilPass
(guardrail.ts:178)。它的意思是:流式场景下,先把内容压住,等护栏判定通过了再放给下游——
宁可牺牲一点「首字延迟」,也不让未经校验的内容先漏出去。这是「安全优先于体感」的取舍。
护栏的 execution 默认 blocking(:177),即护栏跑不完就不放行。
内置的流式护栏(如脱敏)靠 state 跨块累积上下文——比如一个信用卡号被切成两块吐出来,
streamHandler 用 state 记住「上一块结尾有几个悬空数字」,拼起来再判断该不该脱敏
(guardrails/defaults.ts:130 起的几个内置实现)。
5. 反馈与评估:跑完之后的质量闭环
5.1 人类反馈:给某条回复挂个「赞/踩」的把手
agent/feedback.ts 负责把「用户对某条 assistant 消息的反馈」落到记忆里。它不做打分逻辑,只做
元数据管理:
markFeedbackProvided(feedback.ts:72):按userId/conversationId/messageId找到目标 消息,往它的metadata.feedback上盖provided: true+ 时间戳,再写回记忆。createFeedbackHandle(feedback.ts:202):返回一个「反馈把手」对象,带isProvided()和markFeedbackProvided()两个非枚举方法,让调用方能优雅地问「反馈给了吗」并补记。findFeedbackMessageId(feedback.ts:146):从后往前找带匹配tokenId(或 traceId+key+url) 的 assistant 消息——把「一个反馈令牌」对回「哪条消息」。
反馈令牌本身由 VoltOps 签发(见 §6 的 createFeedbackToken),前端拿令牌就能让终端用户打分,
分数最终对回 trace。
5.2 自动评估:异步打分,不阻塞返回
agent/eval.ts 是「打分器(scorer)」的运行时。设计上最重要的一点:评估是异步的,绝不拖慢
用户拿到回复。入口 enqueueEvalScoring(eval.ts:338):
agent 产出结果
│
▼
enqueueEvalScoring(host, { output, operation })
│ ① 没配 scorers → 直接 return(零开销)
│ ② 在 root span 上盖 eval.* 属性(scorer 数量/触发源/采样率)
│ ③ buildEvalPayload 打包这次运行的输入输出
▼
scheduleAsync(() => runEvalScorers(...)) ← setImmediate/setTimeout,丢到下一个 tick
│
▼ [此时用户已经拿到回复了]
每个 scorer 跑一遍 → 分数/阈值/是否通过 → 写进 span + 回传 VoltOps
scheduleAsync(eval.ts:36)优先用 setImmediate,把打分推迟到当前调用栈之后,所以对主流程
零阻塞。AgentEvalHost(eval.ts:322)是 eval 和 agent 之间的窄接口——只暴露 id/name/logger/
evalConfig 和「拿 observability、拿 VoltOpsClient」两个回调,让 eval 模块不依赖整个 Agent。
每个 scorer 的结果会被建成一个 span(createScorerSpanAttributes,eval.ts:125),挂在
root span 下,分数(score)、阈值(threshold)、是否通过(thresholdPassed)都进 trace——
于是「质量分」和「运行 trace」在 VoltOps 里天然对齐,能一起看。
6. VoltOps 控制面:远程 prompt 与数据回传
6.1 VoltOpsClient:一个客户端,多种职责
VoltOpsClient(voltops/client.ts:75)是 VoltAgent 连到 VoltOps 云端的统一客户端。历史上它还管
observability 导出,现在那部分已迁到 VoltAgentObservability,它如今集中在四件事:
| 能力 | 入口 | 说明 |
|---|---|---|
| 远程 prompt | client.prompts(VoltOpsPromptManager) | 从云端拉 prompt,带缓存和模板 |
| 反馈令牌/反馈 | createFeedbackToken(:255)、createFeedback(:297) | 签发令牌、回传用户打分 |
| 评估回传 | evals.runs.*(:111) | 创建/追加/完成评估 run |
| 托管记忆 | managedMemory(:455) | 走云端的消息/向量/工作流状态存储 |
key 校验是启用开关。 构造时校验 publicKey 以 pk_ 开头、secretKey 以 sk_ 开头
(hasValidKeys,:203);无效就什么服务都不初始化——所以填错 key 不会崩,只是静默降级。
getAuthHeaders(:231)把两把 key 塞进 X-Public-Key/X-Secret-Key,给所有请求和上面
LazyRemoteExport 的 OTLP 导出复用。