跳到主要内容

现代 IORails 引擎、服务端与可观测性

30 秒导读: 前面几章讲的是 LLMRails——那套基于 Colang 事件、能跑任意对话流程的老引擎。本章讲的是新一代入口:一个叫 IORails专用推理引擎,它只做「输入/输出护栏」这一件事,但做得又快又能上生产:并发队列、推测执行、流式边生成边检查、统一的模型调用层,以及三条互不绑定的可观测性信号(tracing / metrics / usage telemetry)。最后是把这一切包成 HTTP 服务的 FastAPI 服务端(OpenAI 兼容接口)。

本章的范围是引擎选择、服务化与观测,不重复各 rail 内部的检测算法——那些在 04-input-output-rails-and-library.md;LLM 抽象与 prompt 渲染在 05-llm-and-prompting-layer.md;Colang 事件运行时在 03-event-runtime-and-dialog.md


1. 这是什么(零基础也能懂)

一句话定义

IORails 是一个「把护栏当成一个推理微服务来跑」的引擎。 它把「检查用户输入 → 调主模型 → 检查模型输出」这条流水线,做成一个能扛住高并发、能流式返回、能被 OpenTelemetry 观测的东西。

为什么会有两套引擎

老引擎 LLMRails(前几章的主角)非常强:它能跑任意 Colang 对话流程、能做多轮记忆、能注册自定义 action。代价是它是事件驱动、有状态、逻辑复杂——放到生产环境里当「护栏网关」跑,偏重。

很多真实场景其实只需要一件事:

「我有一个主 LLM。请在它前面加一道输入检查、在它后面加一道输出检查,别的什么都不要,但要、要能流式、要能接进我的 OTEL 监控。」

IORails 就是为这个场景生的。它故意只支持一小撮护栏类型,换来的是简单、无状态、可水平扩展。

用户其实不直接选——门面帮你选

你日常用的类是 Guardrails(见 guardrails/guardrails.py)。你给它一个 config,它自动判断:这个 config 里的东西 IORails 扛得住吗?

  • 扛得住 → 用 IORails(快)。
  • 扛不住(比如你写了自定义 Colang 流程、或传了自定义 llm)→ 自动回落LLMRails,并打一条 warning。

所以对使用者来说,大多数时候你根本感觉不到底下换了引擎。

用起来什么样

# 示意,非源码 —— 展示门面的默认体验
from nemoguardrails import RailsConfig
from nemoguardrails.guardrails.guardrails import Guardrails

config = RailsConfig.from_path("./my_config") # 里面只配了 input/output 护栏
rails = Guardrails(config) # 默认 use_iorails=True

# 非流式:一问一答
answer = await rails.generate_async(prompt="帮我写封邮件")

# 流式:边生成边过输出护栏
async for chunk in rails.stream_async(prompt="讲个故事"):
print(chunk, end="")

如果 config 里有 IORails 不支持的东西,同样这段代码会静默回落LLMRails,行为不变,只是慢一点、多一条日志。

一句话直觉

IORails 想成一台只会做「安检 → 放行 → 出口复检」的流水线机器;把 LLMRails 想成一个能按剧本演任意对话的演员。门面 Guardrails 是前台,看你点的菜决定派哪个上。


2. 顶层全景(它大概怎么转)

2.1 三层结构

你的代码
│ generate_async / stream_async

┌───────────────────────────────────┐
│ Guardrails (门面 / 前台) │ guardrails/guardrails.py
│ 构造时:IORails 扛得住吗? │
└───────────────┬───────────────────┘
扛得住 │ │ 扛不住 / 传了 llm / Colang 2.x
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ IORails (新引擎) │ │ LLMRails (老引擎) │ 见 03/04 章
│ guardrails/ │ │ 事件驱动 + Colang │
│ iorails.py │ └──────────────────────┘
│ │
│ 队列 / 推测 / 流式 │
└─────────┬──────────┘
│ model_call / stream_model_call

┌────────────────────────────────┐
│ EngineRegistry (引擎注册表) │ guardrails/engine_registry.py
│ 按 model.type 找到对应引擎 │
│ ┌──────────────┐ ┌──────────┐ │
│ │ ModelEngine │ │APIEngine │ │ model_engine.py / api_engine.py
│ │ (HTTP 聊天补全)│ │(如越狱NIM)│ │
│ └──────────────┘ └──────────┘ │
└────────────────────────────────┘
│ 贯穿始终:三条独立观测信号

tracing (OTEL span) · metrics (OTEL 指标) · usage telemetry (匿名统计)

2.2 部件一句话职责

部件干什么在哪个文件
Guardrails门面:构造时选引擎、把公共方法转发给选中的引擎guardrails/guardrails.py
IORails新引擎:输入/输出/工具护栏的编排 + 并发控制 + 流式guardrails/iorails.py
LLMRails老引擎:事件驱动 Colang 全流程(回落目标)rails/llm/llmrails.py(见 03 章)
RailsManager具体跑各个 rail(is_input_safe / is_output_safe 等)guardrails/rails_manager.py(见 04 章)
EngineRegistry按名字路由到具体引擎;所有模型/API 调用的唯一出口guardrails/engine_registry.py
ModelEngine一个模型 = 一个 HTTP 客户端(带重试/超时)guardrails/model_engine.py
RailAction单个 rail 的模板方法骨架(抽取→prompt→调用→解析)guardrails/rail_action.py
Toolset/Tool/ToolResult工具护栏用的中立化内部数据结构guardrails/tool_schema.py
inline OTELIORails 请求期实时打 span / 指标guardrails/telemetry.py
post-hoc tracingLLMRails 事后把日志转成 span 导出nemoguardrails/tracing/
usage telemetry匿名采用度统计(可 opt-out)nemoguardrails/telemetry.py
FastAPI serverOpenAI 兼容的 /v1/chat/completionsserver/api.pyserver/app.py

2.3 主线走一遍(非流式)

一次 generate_async,高层看是这样:

消息进来
→ [门面] 转发给 IORails.generate_async
→ [队列] 提交到 admission queue(限并发,满了拒绝)
→ [request 作用域] 开一个 guardrails.request span + request_id
→ 工具结果护栏(如果消息里带 tool 结果)
→ 输入护栏 ──(推测模式下与下一步并行)──┐
→ 调主模型 (EngineRegistry.model_call) ─┘
→ 工具调用护栏(如果模型要调 tool)
→ 输出护栏(检查最终文本)
→ 返回 assistant 消息(或被拦时返回 REFUSAL_MESSAGE)

真正的核心方法是 iorails.py:_do_generate(iorails.py:517),它按顺序把这几步串起来。下面逐个拆。


3. 核心机制一:门面如何在两套引擎间选择

它要解决的小问题

用户只想调一个 generate_async,不想操心「我这 config 该用新引擎还是老引擎」。门面得自己判断并且判断错时安全回落

思路

判断逻辑不写在门面里,而是让 IORails 自己声明「我能处理什么」。门面只问一句:IORails.unsupported_reason(config, llm) 返回 None 吗?

  • 返回 None → 能处理,用 IORails。
  • 返回一个字符串(人类可读的原因)→ 不能,回落 LLMRails,把这句原因打进 warning。

真实实现

门面构造函数里的分叉:

# guardrails/guardrails.py:81 —— Guardrails.__init__
if use_iorails:
fallback_reason = IORails.unsupported_reason(config, llm)
if fallback_reason is None:
self._rails_engine = IORails(config)
self.use_iorails_engine = True
else:
message = (f"... IORails cannot be used: {fallback_reason}. Falling back to LLMRails; ...")
if require_iorails:
raise ValueError(message) # 要求必须用 IORails 时,直接报错而不是回落
log.warning(message)
self._rails_engine = LLMRails(config, llm, verbose)
self.use_iorails_engine = False

require_iorails=True 是给「我一定要 IORails 独有功能(比如 OTEL metrics)」的人用的:宁可报错也不偷偷降级。

IORails 到底「能处理什么」

判定的真源在 iorails.py:239IORails.unsupported_reason。它是一串从便宜到贵的短路检查,任一命中就返回原因:

检查项不支持的情形源码锚点
自定义 LLM传了 llm 参数(IORails 不接受外部 LLM)iorails.py:241
Colang 版本config.colang_version != "1.0"iorails.py:244
rail 分区用了 SUPPORTED_RAILS 之外的分区iorails.py:247
流方向某条 flow 不在该方向的 SUPPORTED_*_FLOWSiorails.py:260
重复工具流同一工具 flow 出现两次(否则 RailsManager 构造时会抛错)iorails.py:269

can_handle 只是 unsupported_reason(...) is None 的薄包装(iorails.py:280)。

IORails 白名单很小,这是它简单的根本原因:

SUPPORTED_RAILS = {input, output, config, tool_input, tool_output}
SUPPORTED_INPUT_FLOWS = {content safety check input,
topic safety check input,
jailbreak detection model}
SUPPORTED_OUTPUT_FLOWS = {content safety check output}
SUPPORTED_TOOL_OUTPUT = {tool call validation} # 只放校验「模型发起的调用」
SUPPORTED_TOOL_INPUT = {tool result validation} # 只放校验「回给模型的结果」

iorails.py:226-236。注意工具流是方向特定的:tool_output 只允许 call 校验器、tool_input 只允许 result 校验器。放错方向 = 回落 LLMRails,而不是运行期崩。

门面转发的两副面孔

Guardrails 里大量方法长这样:先判断底层是不是 IORails,是就抛 NotImplementedError。因为 IORails 无状态、不支持 Colang 特性。可对照:

  • IORails 支持的:generate / generate_async / stream_async(guardrails.py:207-272)。
  • IORails 不支持、只有 LLMRails 有的:explain()runtimegenerate_events*process_events*check*、所有 register_*(如 guardrails.py:274-474)。
  • 无状态的体现:events_history_cache 对 IORails 直接返回 {} 并丢弃写入(guardrails.py:297-319),因为它不跨请求存对话。

关键细节: stream_async 转发时会过滤 kwargs——IORails 的 stream_async 只认 optionsinclude_metadata,别的会被 warning 掉丢弃(guardrails.py:258-269)。这是两套引擎签名不一致的适配层。


4. 核心机制二:IORails 的三种生成策略

IORails 编排护栏的核心方法是 _do_generate(iorails.py:517)。它先跑工具结果护栏,然后根据配置在顺序推测两条路里选一条拿到主模型响应,最后跑工具调用护栏 + 输出护栏。

流式则是第三条独立的路(stream_async),下一节单讲。

4.1 顺序生成(默认,最安全)

要解决的小问题: 最朴素——先检查输入,安全了再调模型。省钱(输入不安全就不调模型),但慢(串行)。

# iorails.py:595 —— IORails._do_generate_sequential
input_result = await self.rails_manager.is_input_safe(messages, enabled=input_enabled)
if not input_result.is_safe:
if self._metrics_enabled:
record_request_blocked(RailDirection.INPUT)
return None # None → 上层返回 REFUSAL_MESSAGE
return await self.engine_registry.model_call("main", messages, **llm_kwargs)

4.2 推测生成(用延迟换钱)

要解决的小问题: 输入护栏本身也要调一次 LLM(内容安全模型),它和主模型生成是串行的,延迟叠加。能不能让两者同时跑?

思路(推测执行): 赌「输入大概率是安全的」。于是同时启动「输入护栏」和「主模型生成」两个 task,赛跑:

  • 若护栏先判定不安全 → 立刻 cancel 掉主模型生成(浪费了那次调用,但换回了不放行不安全内容)。
  • 若护栏判定安全 → 主模型的结果已经在算了,直接用,省下了串行等待。

config.rails.input.speculative_generation 开启(iorails.py:326)。

# iorails.py:610 —— IORails._do_generate_speculative(节选)
rails_task = asyncio.create_task(self.rails_manager.is_input_safe(messages, enabled=input_enabled))
gen_task = asyncio.create_task(self.engine_registry.model_call("main", messages, **llm_kwargs))
response = await self._parallel_input_rail_and_response_generation(rails_task, gen_task, req_id, request_span)

赛跑裁决在 _parallel_input_rail_and_response_generation(iorails.py:654),用 asyncio.wait(..., FIRST_COMPLETED):

┌───────────── 谁先完成? ─────────────┐
▼ ▼
护栏先完成 生成先完成
├─ 不安全 → cancel 生成, 返回 None └─ 再 await 护栏结果
└─ 安全 → await 生成结果 ├─ 不安全 → 丢弃已生成结果, 返回 None
└─ 安全 → 用已生成结果

巧妙/坑点(异常与取消的收尾): 推测路最难写对的是清理。看 iorails.py:629-650:出异常时把两个 task 都 cancel,然后用 asyncio.gather(..., return_exceptions=True) 把它们的异常都收干净,避免 asyncio 抛 "Task exception was never retrieved" 警告;再把「不是正在重抛的、也不是 CancelledError 的」真实错误单独 log 出来,免得被吞掉。裁决内部取消 gen_task 时同样用 gather(return_exceptions=True) 而不是裸 await,因为两 task 同刻完成时 gen_task 可能带着一个存好的异常(iorails.py:676-681)。

推测执行还顺带打了 span 属性:哪个先完成、最终是谁「胜出」,写进 request span(set_speculative_span_attrs,iorails.py:684),方便事后分析推测命中率。

4.3 对称结构:输入/输出 与 工具输入/输出

_do_generate(iorails.py:517-593)有一个漂亮的对称设计:

方向检查谁何时跑
tool_input(工具结果护栏)客户端执行完工具、把结果回给模型最前,和 INPUT 对称(iorails.py:535)
input(输入护栏)用户消息顺序/推测生成里
tool_output(工具调用护栏)模型发起的 tool 调用是否合法模型返回后,和 OUTPUT 对称(iorails.py:563)
output(输出护栏)模型最终文本最后(iorails.py:581)

几个值得记的细节:

  • 推理内容绕过输出护栏。 <think>...</think> 或 provider 的 reasoning 字段会被抽出(iorails.py:558),输出护栏只查最终答案;之后推理再以 <think> 标签重新贴回 content(iorails.py:590)。这与 LLMRails 行为一致。
  • 纯工具调用响应跳过输出护栏。 若模型只返回 tool 调用、没有文本(is_tool_call_only,iorails.py:578),就没有文本可查,直接跳过 is_output_safe
  • 被拦一律返回同一句 REFUSAL_MESSAGE,不泄露是哪条 rail 拦的(iorails.py:540/571/586)。

5. 核心机制三:流式生成与「边生成边检查输出」

流式是 IORails 最工程化的一段(stream_async,iorails.py:722)。难点:用户要求 token 一个个吐出来,但输出护栏要看一段文本才能判断——怎么既流式又能拦?

5.1 两条子路

stream_async

有配置输出护栏 且 streaming.enabled?

┌─────┴─────────────────────┐
否 是
│ │
直接把 handler 的 走 _run_output_rails_in_streaming:
token 转发给调用方 把 token 攒成批 → 每批过一次输出护栏

分叉点在 iorails.py:949:self._has_streaming_output_rails 为真就套一层 _run_output_rails_in_streaming,否则直接用裸 streaming_handler

_has_streaming_output_rails 的定义(iorails.py:352):有输出 flow rails.output.streaming.enabled

5.2 生成 task 与消费迭代器分离

stream_async 内部起了两个协程:

  • _generation_task(iorails.py:785):后台跑「工具结果护栏 → 输入护栏 → 流式调主模型」,把 token push_chunk 进一个 StreamingHandler 队列;拦截/出错时 push 一个 JSON 错误负载再 push END_OF_STREAM
  • _wrapped_iterator(iorails.py:890):对外的 async 迭代器,从 handler(或套了输出护栏那层)拉 chunk 交给调用方,并负责并发信号量、span、内容捕获、以及收尾时把攒好的 tool 调用作为最后一个 chunk 吐出(前提是没出错、没被拦,iorails.py:980)。

5.3 输出护栏在流里怎么跑

_run_output_rails_in_streaming(iorails.py:1033)复用了和 LLMRails 完全相同RollingBuffer + stream_first 语义:

  • stream_first=True:先把 token 吐给用户,再对累计文本跑输出护栏;若不安全,注入一个错误 chunk 并停止(用户已经看到一部分——延迟优先)。
  • stream_first=False:先跑输出护栏,安全才吐这一批 token(安全优先,延迟高)。
# iorails.py:1085 —— 每攒够一批就查一次
output_result = await self.rails_manager.is_output_safe(messages, bot_response_chunk, enabled=enabled)
if not output_result.is_safe:
violation = self._guardrails_violation_payload(f"Blocked by output rails: {output_result.reason}", "output_rails")
yield _frame_for_stream(violation, include_metadata)
return

坑点:生成错误不能喂给输出护栏。 若某批里混进了 _generation_task 产生的生成错误 JSON,_run_output_rails_in_streaming 会识别出来直接透传并 return,而不是把错误 JSON 当正文送进 is_output_safe(iorails.py:1062-1069)。

5.4 流式的边界约束

stream_async 开头做了三道校验:

  • 推测生成 + 流式 → 不支持,警告后回落顺序(iorails.py:754)。
  • 有输出护栏但 streaming.enabled=False → 抛 StreamingNotSupportedError,让调用方改用 generate_async(_validate_streaming_with_output_rails,iorails.py:712)。
  • include_metadata=True + 输出护栏流式 → 抛 ValueError,因为 BufferStrategy 只吃纯字符串 chunk(iorails.py:761)。

服务端的 Chat UI 正是靠捕获 StreamingNotSupportedError 来在流式不可用时回落到非流式的(server/app.py:136)。


6. 核心机制四:引擎注册表与模型引擎抽象

IORails 从不自己发 HTTP。所有对模型/API 的调用都经 EngineRegistry(engine_registry.py:57)。这层存在的意义有三:统一路由、统一观测、每个模型独立的 HTTP 客户端。

6.1 注册表:一个模型 = 一个引擎

构造时按 config.models 逐个建 ModelEngine,以 model_config.type 为 key(engine_registry.py:96)。越狱检测这种走独立 API 的,建成 APIEngine(engine_registry.py:106)。

EngineRegistry._engines = {
"main" : ModelEngine(...) # 主模型
"content_safety" : ModelEngine(...) # 内容安全模型
"jailbreak_detection": APIEngine(...) # 越狱检测 NIM
}

护栏代码只说「调 main」「调 content_safety」,注册表负责查表 + 类型校验(_get_engine,engine_registry.py:165)。

6.2 两个统一出口:model_call 与 stream_model_call

方法返回用在
model_call结构化 LLMResponse(content / reasoning / usage / finish)非流式主生成、各 rail 的 LLM 调用
stream_model_callAsyncGenerator[LLMResponseChunk]流式主生成

engine_registry.py:175(model_call)和 :239(stream_model_call)的关键设计:把「合并参数 → 建 span → 计时 → 调引擎 → 记录响应/用量属性 → 发 token 指标」编织成一体

# engine_registry.py:201 —— 每次调用:引擎默认参数 + 本次 kwargs(本次优先)
merged_params = {**engine.body_param_defaults, **kwargs}
with llm_call_span(self._tracer, engine.model_name, provider_name, operation_name) as span:
set_llm_request_attributes(span, merged_params) # 请求参数先落 span(即使调用抛错也有)
with duration_ctx: # duration 指标(仅 metrics 开启时)
result = await engine.chat_completion(messages, **merged_params)
set_llm_response_attributes(span, model=result.model, ..., usage=result.usage)

这就是为什么 nemoguardrails/AGENTS.md 里强调「LLM 调用要走共享抽象,别自己发请求」——绕过 EngineRegistry 就等于绕过了 tracing/metrics/参数合并。

流式的观测更细(stream_model_call,engine_registry.py:239):span 覆盖整个生成器生命周期;逐 chunk 记「首 chunk 时间 / chunk 间时间」(record_time_to_first_chunk / record_time_per_output_chunk,engine_registry.py:329-333);usage 一般只在末尾 SSE chunk 出现,所以每个响应字段都「保留最后一个非空值」(engine_registry.py:335-346)。消费方取消或 provider 中途报错时,故意不记录部分响应(engine_registry.py:350-372)。

6.3 ModelEngine:一个模型的 HTTP 化身

ModelEngine(model_engine.py:450)继承 BaseEngine(base_engine.py),后者管一个 aiohttp_retry.RetryClient 的生命周期——每个模型有自己的超时、重试(指数退避)、连接池:

  • 超时/重试从 model.parameters 读(timeout / timeout_connect / max_attempts,model_engine.py:465)。
  • body_param_defaultsmodel.parameters 去掉保留键后的只读视图(MappingProxyType),作为每次推理的默认 body 参数(model_engine.py:474)。
  • _resolve_base_url 会剥掉尾部 /v1,让用户能照 OpenAI 习惯写 base_url 而不产生 /v1/v1/...(model_engine.py:478)。
  • API key 解析:优先 config 指定的 env 变量,其次按 engine 猜(nimNVIDIA_API_KEYopenaiOPENAI_API_KEY),都没有就当本地模型不需要 key(model_engine.py:507)。

对外暴露 chat_completion(:769)与 stream_chat_completion(:789),内部 call(:575)/stream_call(:623)负责真正的 POST 与 SSE 解析。

6.4 RailAction:每个 rail 的模板方法骨架

RailAction(rail_action.py:47)是所有 IORails rail 的抽象基类,定义了一条模板方法流水线:

run(flow, messages, bot_response):
action_span 包裹
→ _validate_flow_name 校验 flow 名对得上
→ _get_model_type 从 flow 的 $model= 解析模型(或 fallback_model)
→ _extract_messages 从消息里抽相关字段 [子类实现]
→ _create_prompt 组请求负载 [子类实现]
→ _get_response 调模型/API/本地 [子类实现]
→ _parse_response → RailResult 解析成安全判定 [子类实现]

rail_action.py:77-108。基类提供三种「响应」帮手:_get_llm_response(走 EngineRegistry.model_call)、_get_api_response(走 api_call)、_get_local_response(本地检查)。异常被兜住:记 span 错误 + 返回 RailResult(is_safe=False, reason=...)(rail_action.py:104-108)——rail 出错默认判不安全(fail-closed)。

6.5 工具护栏的中立数据结构

工具护栏不直接看 OpenAI/Anthropic 的原始 wire 格式,而是先由引擎适配器归一化成 tool_schema.py 里的中立类型:

类型是什么锚点
Tool一个声明的工具定义(调用方提供的)tool_schema.py:42
Toolset一次请求声明的工具集合,按 key 索引、拒重复tool_schema.py:66
ToolCall模型发起的调用(在 nemoguardrails.types)
ToolResult从消息里抽出的归一化工具结果tool_schema.py:102
ToolExchange一个 assistant 回合的 (calls, results) 配对tool_schema.py:120

字段名刻意 provider 中立:arguments_schema = OpenAI parameters / Anthropic input_schema;ToolResult.call_id = OpenAI tool_call_id / Anthropic tool_use_id(见 tool_schema.py:24-30 的注释)。校验入口 validate_arguments(tool_schema.py:160)用 jsonschema 校验模型给的参数;声明「无参数」的函数工具被塞了参数就拒(_no_arguments_reason,tool_schema.py:146)。适配器目前落地的是 OpenAI Chat Completions(tool_schema.py:30)。


7. 核心机制五:三条独立的可观测性信号

这是本章的重头。NeMo Guardrails 有意把观测拆成三条互不绑定的信号——nemoguardrails/AGENTS.md 明写「Keep observability signals independently configurable」。三者契约不同,不能当一个开关打包开关。

信号1. Tracing(分布式追踪)2. Metrics(聚合指标)3. Usage Telemetry(匿名采用度统计)
作用单请求的 span 树计数 / 直方图 / 仪表(SLO 仪表盘)「谁在用、用了哪些功能」上报到 NVIDIA
开关config.tracing.enabledconfig.metrics.enabled环境变量 opt-out(默认开,可关)

对应配置见 config.py:TracingConfig(config.py:615)、MetricsConfig(config.py:649);IORails 构造时分别解析这三个开关(iorails.py:291-297)。

7.1 Tracing——而且有两套实现

这是最容易搞混的一点:tracing 有两条不同代码路径,取决于用哪个引擎。

IORails(新)LLMRails(老)
时机inline / 实时:请求过程中就开 spanpost-hoc / 事后:生成完把 GenerationLog 抽成 span
实现guardrails/telemetry.pynemoguardrails/tracing/(Tracer+ adapters)
导出依赖宿主配置的 OTEL TracerProvider通过 adapters(filesystem / opentelemetry)
入口traced_requestguardrails.request spanTracer(...).export_async()(llmrails.py:1302)

nemoguardrails/tracing/老路:Tracer(tracing/tracer.py:36)拿生成日志,generate_interaction_log 抽成 span,再交给 adapters 导出;create_log_adapters(config.tracing)config.tracing.adapters 建导出器(tracing/tracer.py:104)。两套都由同一个 TracingConfig 驱动,但字段用途不同:adapters/span_format 只对 LLMRails 那套有意义,IORails inline 路径忽略它们(telemetry.py:896is_tracing_enabled 只看 enabled + OTEL 是否装了)。

IORails inline span 树(名字见 tracing/constants.py:229-233):

guardrails.request (SERVER span, 每请求一个, 附 request_id)
├── guardrails.rail (每条 rail)
│ └── guardrails.action (rail 内的 action)
│ └── "chat <model>" (LLM CLIENT span, 带 gen_ai.* 属性)
└── "api <name>" (API 调用 span)

span 辅助函数都在 guardrails/telemetry.py:request_span(:756)、rail_span(:781)、action_span(:809)、llm_call_span(:832)、api_call_span(:870)。核心编排是 traced_request(telemetry.py:1130),它把「设 request_id + 可选 span + 可选 metrics」统一起来——tracer 与 metrics 两个信号在这里就是分别 gate 的(telemetry.py:1155-1157),四种组合都合法,包括「只要 metrics 不要 trace」。

内容捕获(content capture)是 tracing 的子开关,不是第四条信号:只有 tracing 开着时才捕获 prompt/response 到 span(iorails.py:294-297)。它受 OTEL 标准环境变量 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT 最高优先级控制,再回落到 config.tracing.enable_content_capture(telemetry.py:917)。默认关——因为可能含 PII(config.py:625 的字段说明)。

7.2 Metrics——OTEL 指标,独立于 tracing

config.metrics.enabled 开启,are_metrics_enabled(telemetry.py:947)校验(需装 OTEL + 开关为真)。docstring 明确:独立于 tracing——为的是让人能跑「metrics-only」的廉价 SLO 仪表盘而不背 trace 导出开销。

两组指标:

1) GenAI 客户端指标(OTEL GenAI 语义约定,由 EngineRegistry 发):

指标含义锚点
gen_ai.client.operation.duration一次模型调用/整段流的耗时engine_registry.py:209
gen_ai.client.token.usageinput/output token 数(各一次观测)engine_registry.py:234/372
首 chunk / chunk 间时延流式质量engine_registry.py:329-333

2) Guardrails 请求级指标(RequestInstruments,telemetry.py:145):

指标类型何时动
guardrails.requestsCounter每请求 +1
guardrails.request.durationHistogram请求结束记时长(含排队等待)
guardrails.requests.errorsCounter异常时 +1,带 error.type
guardrails.requests.blockedCounter被 rail 拦时 +1,带 rail.type
guardrails.requests.activeUpDownCounter在途请求数
guardrails.nonstream.rejectionsCounter非流式队列满被拒
guardrails.stream.rejectionsCounter流式并发满被拒
guardrails.stream.activeUpDownCounter活跃流数
guardrails.nonstream.{queued,active}ObservableGauge队列饱和度(需活队列引用,start() 时懒注册)

请求级指标由 request_metrics(telemetry.py:1087)在生成外层包裹,所以 duration 包含队列等待时间(符合 OTEL HTTP 语义)。有个诚实的双信号设计:非流式队列满时,QueueFull 同时体现在 requests.errors{error.type=QueueFull} nonstream.rejections(iorails.py:459-471)。

为什么流式要手动补错误计数: 流式路径把异常吞掉转成错误 chunk,request_metricsexcept 分支永远不会被触发,所以 _generation_task 里显式调 record_request_error(iorails.py:879telemetry.py:1020),保证错误计数覆盖所有失败请求。

7.3 Usage Telemetry——匿名采用度统计

这是完全不同的东西:上报「有人在用、用了哪些内置功能、跑的是哪个引擎」到 NVIDIA 的遥测端点,用于产品采用度分析。实现在 nemoguardrails/telemetry.py(注意不是 guardrails/telemetry.py)。

IORails 构造时会 fire-and-forget 上报一次(iorails.py:346-349):

# iorails.py:346
if _report_usage:
from nemoguardrails.telemetry import RailsEngineEnum, report_usage
report_usage(config, deployment_type="library", rails_engine=RailsEngineEnum.IORAILS.value)

RailsEngineEnum(telemetry.py:191)区分 IORAILS / LLMRAILS,DeploymentTypeEnum(telemetry.py:173)区分 library / API(服务端启动时设成 API,server/api.py:134)。

默认开,但多路 opt-out(_is_usage_stats_enabled,telemetry.py:346):NEMO_GUARDRAILS_NO_USAGE_STATS、行业标准的 DO_NOT_TRACK~/.config/nemoguardrails/do_not_track 文件、CI 环境变量、以及检测到 pytest(避免测试流量污染采用度)。它还刻意不采集内容,只采集「用了哪些内置 feature」这类结构化信号(_detect_builtin_features,telemetry.py:507)。

同步 generate() 用的临时 IORails 实例传 _report_usage=False(iorails.py:442),免得把 sync 桥接算成一个新用户实例。


8. 服务化:FastAPI 服务端

把上面一切包成 HTTP 服务的是 server/

8.1 三个入口文件

文件是什么
server/api.pyFastAPI 应用与所有路由
server/app.pyChainlit 聊天 UI(挂在 api 之上)
server/schemas/openai.pyOpenAI 兼容的请求/响应 Pydantic 模型

appGuardrailsApp(FastAPI) 子类(api.py:66),带 lifespan(api.py:131)在启动时注册 challenges、探测单/多 config 模式、设 deployment_type=API。

8.2 OpenAI 兼容接口

主端点 /v1/chat/completions(api.py:482),请求体是 GuardrailsChatCompletionRequest(schemas/openai.py:154):它继承标准 OpenAI 请求字段(OpenAIChatCompletionRequest,:46:messages/model/stream/temperature/tools/...),再叠一个 guardrails 专属块 GuardrailsDataInput(:107,含 config_id/config_ids/thread_id/options 等)。响应 GuardrailsChatCompletion 继承 openai.typesChatCompletion,附一个 guardrails 输出块(:40)。

这套「标准 OpenAI 字段 + guardrails 扩展块」的设计,意味着现有 OpenAI 客户端几乎零改动就能指向 Guardrails 服务器。

其余路由:/v1/rails/configs(:229)、/v1/models(:254)、/v1/checks(:673,只跑 rail 不生成)、/v1/challenges(:754)。

8.3 端点内部做了什么

chat_completion(api.py:487)的编排:

校验 config_id(取请求的, 否则取服务器默认, 都没有 → 422)
→ _validate_public_state_shape (状态形状校验)
→ _get_rails(config_ids, model) 拿到 rails 实例(带缓存)
→ 版本/线程约束: 有状态续接 & thread_id 仅 Colang 1.0 支持 (422)
→ tools/tool_choice 仅在 passthrough+非流式 时允许 (422)
→ thread_id → 从 datastore 取历史消息前置拼接
→ 把 OpenAI 参数(max_tokens/temperature/tools/...) 塞进 options.llm_params
→ stream? StreamingResponse(SSE) : generate_async → OpenAI 响应

安全相关细节:

  • API key 从不回显——schemas/openai.py 的输出块只含 config_id/state/log 等(:27),遵循 AGENTS.md 「secrets belong in headers」。
  • thread_id 有最小长度 16 校验(api.py:566),防弱 key 撞库读别人会话。
  • 流式响应封装成 text/event-stream 的 SSE(api.py:616),经 _format_streaming_response(api.py:416)逐 chunk 转 OpenAI delta 格式,并把内部错误 chunk 归一成 ChunkError(api.py:449)。

注(inferred): 这版 server/api.pychat_completion 直接对 _get_rails 返回的 LLMRailsstream_async/generate_async(api.py:517/610/621),而不是经门面 Guardrails。也就是说引擎选择目前主要发生在库/门面层,服务端这条路径绑定的是 LLMRails;IORails 的 stream_async/generate_async 与之保持签名兼容,所以对上层是可替换的。


9. 生命周期与并发控制(生产化的底座)

IORails 是有生命周期的对象(不像 sync generate 那样临时起停):

  • start()(iorails.py:357):启动引擎注册表(建各模型的 HTTP 客户端)+ 启动非流式队列 + 懒注册饱和度 gauge。有严谨的回滚:gauge 注册失败回滚队列、队列启动失败回滚注册表,保证重试从干净状态开始(iorails.py:365-394)。
  • stop()(iorails.py:398):各步独立收尾,_running 无论如何清掉,避免泄漏 worker task。
  • 门面 Guardrails_started 标志做懒启动:首次 generate_async 自动 startup()(guardrails.py:202/497),也支持 async with 上下文管理器。

两道并发闸门(常量都是 256,iorails.py:80-86):

路径机制满了怎样
非流式AsyncWorkQueue admission 队列(reject_on_full)asyncio.QueueFull + 记 nonstream.rejections
流式asyncio.Semaphore 信号量非阻塞 acquire,满了抛 QueueFull + 记 stream.rejections

流式信号量用了一个 race-free 小技巧:locked() 检查与 acquire() 之间没有 await,asyncio 协作式调度下不会被别的协程插入(iorails.py:911 的注释)。


10. 边界与局限(诚实)

  • IORails 无状态、不支持对话/Colang。 没有多轮记忆、没有 generate_events/process_events/check、没有任何 register_*(全在 guardrails.py 里抛 NotImplementedError)。要这些就得 LLMRails。
  • 白名单极窄。 只支持极少数 flow(见 §3),config 稍复杂就回落 LLMRails。这是刻意的取舍:简单换性能。
  • 推测生成不支持流式(iorails.py:754 警告后回落顺序),且推测会浪费被取消的那次模型调用。
  • 工具护栏强制串行。 tool_output.parallel/tool_input.parallel 被忽略并警告——工具 rail 是 CPU-bound,不等 IO(iorails.py:307)。
  • 工具适配器目前只有 OpenAI Chat Completions(tool_schema.py:30);字段虽 provider 中立,其它 provider 的抽取还没落地。
  • 服务端引擎路径:如 §8.3 note,当前服务端主体路径走 LLMRails;把 IORails 完整接进 server 仍在演进。
  • 观测依赖宿主配置。 OTEL span/指标只有在应用侧配了 TracerProvider/MeterProvider 时才真导出;否则 API 返回 no-op,静默丢弃(telemetry.py:171 的说明)。没装 opentelemetry-api 时开启 tracing/metrics 会 warning 并降级为关(telemetry.py:906/959)。

11. 横向对比(同 shelf / 同项目)

  • 同项目 LLMRails(见 03/04):事件驱动、有状态、能跑任意 Colang;IORails 是它在「纯 input/output 护栏」场景下的高性能特化。两者由门面自动切换。
  • 护栏检测算法(见 04):本章讲的是怎么编排、怎么调用、怎么观测这些 rail,不讲 rail 内部怎么判「安全/不安全」。
  • LLM 抽象(见 05):LLMRails 走 LangChain/框架抽象;IORails 走自己的 ModelEngine(直发 OpenAI 兼容 HTTP + RetryClient),是一条更轻、更可控的并行路径。

12. 代码地图(导航索引)

主题文件路径符号名
门面选引擎nemoguardrails/guardrails/guardrails.pyGuardrails.__init__rails_engine
IORails 可否处理nemoguardrails/guardrails/iorails.pyIORails.unsupported_reasoncan_handleSUPPORTED_RAILS
生命周期/并发nemoguardrails/guardrails/iorails.pyIORails.startstop_generate_async_queue_stream_semaphore
核心生成编排nemoguardrails/guardrails/iorails.pyIORails._do_generate
顺序/推测生成nemoguardrails/guardrails/iorails.py_do_generate_sequential_do_generate_speculative_parallel_input_rail_and_response_generation
流式生成nemoguardrails/guardrails/iorails.pystream_async_generation_task_wrapped_iterator
流式输出护栏nemoguardrails/guardrails/iorails.py_run_output_rails_in_streaming_has_streaming_output_rails
引擎路由nemoguardrails/guardrails/engine_registry.pyEngineRegistry.model_callstream_model_call_get_engine
模型 HTTP 引擎nemoguardrails/guardrails/model_engine.pyModelEngine.chat_completionstream_chat_completion_resolve_base_url
HTTP 客户端基类nemoguardrails/guardrails/base_engine.pyBaseEngine.startstop
rail 模板方法nemoguardrails/guardrails/rail_action.pyRailAction.run
工具中立类型nemoguardrails/guardrails/tool_schema.pyToolToolsetToolResultvalidate_arguments
inline OTEL(IORails)nemoguardrails/guardrails/telemetry.pytraced_requestrequest_metricsllm_call_spanRequestInstrumentsis_tracing_enabledare_metrics_enabled
post-hoc tracing(LLMRails)nemoguardrails/tracing/tracer.pyTracercreate_log_adapters
usage telemetrynemoguardrails/telemetry.pyreport_usageRailsEngineEnum_is_usage_stats_enabled
观测配置nemoguardrails/rails/llm/config.pyTracingConfigMetricsConfig
FastAPI 路由nemoguardrails/server/api.pychat_completionguardrail_checklifespanGuardrailsApp
OpenAI 兼容 schemanemoguardrails/server/schemas/openai.pyGuardrailsChatCompletionRequestGuardrailsDataInputGuardrailsChatCompletion
Chat UInemoguardrails/server/app.pyon_message_get_rails