跳到主要内容

Colang —— 描述对话流程的 DSL

30 秒导读: NeMo Guardrails 的每一条护栏,归根结底是一段用 Colang 写的对话脚本。 Colang 是一门专门描述"对话该怎么走"的小语言(DSL),文件后缀 .co。它的基本单位叫 flow(流程)——一段"匹配到什么就做什么"的剧本。本章只讲这门语言长什么样、怎么被 解析成结构;至于 flow 被解析出来之后运行时怎么一步步驱动,留给 事件驱动运行时

本章在这套书里的位置:上一章 配置与五类护栏 讲了"护栏分几类、 配置怎么写";本章往下钻一层,讲"每一类护栏内部那段逻辑,到底用什么语言写、怎么被读懂"。


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

一句话定义

Colang = 一门只用来写"对话流程"的领域专用语言。 你不用它写算法、不用它建网站—— 它只干一件事:描述"当对话里发生了 X,就让机器人做 Y"。

为什么需要一门新语言

假设你要给一个 LLM 应用加规矩:"用户问股价 → 先查合规 → 不合规就礼貌拒绝"。 用普通 Python 写,你得手动管理"现在轮到谁说话、上一句是什么意图、匹配上了没"这些状态, 很啰嗦。Colang 把这些对话领域的通用套路变成了语言的一等公民:user(用户说了什么)、 bot(机器人回什么)、flow(一段剧本)、match(等某件事发生)。写护栏就像写剧本台词。

用起来什么样

下面是护栏库里真实的一段 Colang(v1.0 风格),来自内容安全护栏 (nemoguardrails/library/content_safety/flows.v1.co):

define bot refuse to respond
"I'm sorry, I can't respond to that."

define flow content safety check input
$response = execute content_safety_check_input
$allowed = $response["allowed"]

if not $allowed
bot refuse to respond
stop

即使从没见过 Colang,你大概也能猜出它在说什么:

  • define bot refuse to respond —— 定义机器人的一句标准回复"抱歉,我不能回应"。
  • define flow content safety check input —— 定义一段流程:调用安全检查动作, 拿到结果;如果不允许(not $allowed),就让机器人说那句拒绝语,然后 stop 掐断。

这段脚本,就是"输入护栏"背后的全部逻辑。护栏不是黑盒,它是可读的剧本。

一句话直觉

把 Colang 当成"对话的 if-then 剧本语言": flow 是一幕戏,里面按顺序写 "等到(match)什么事发生 → 就(send/bot/do)做什么"。护栏引擎就是这出戏的导演。


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

Colang 有两个版本,先分派再解析

Colang 有两代语法,1.02.x,写法差别很大(下一节详述)。所以拿到一个 .co 文件,第一步是判断它是哪一版,再交给对应的解析器。这个总闸在 nemoguardrails/colang/__init__.py

.co 文件内容

┌─────────────▼──────────────┐
│ parse_colang_file(version) │ __init__.py:24
│ 按 version 选解析器 │
└───┬───────────────────┬─────┘
version=1.0│ │version=2.x
▼ ▼
v1_0/lang/parser.py v2_x/lang/parser.py
多级正则 + YAML 转译 lark 文法 + AST 转换
│ │
▼ ▼
CoYML → CIL 元素 Flow / SpecOp AST
└────────┬─────────┘

运行时消费(见 03)

版本嗅探是个小启发式:_is_colang_v2() 先剥掉注释和三引号内容,然后看关键字—— 行首出现 define 判为 1.0,出现 import 判为 2.x(colang/__init__.py:68-97)。 分派表本身就两行(colang/__init__.py:32-35):

parsers = {
"1.0": parser_v1_0.parse_colang_file, # 示意,非源码简化
"2.x": parser_v2_x.parse_colang_file,
}

各部件一句话职责

部件干什么在哪个文件
版本分派按 version 选解析器,嗅探 1.0/2.xcolang/__init__.py
v1.0 解析器逐行正则规范化 → CoYML → CIL 元素表colang/v1_0/lang/colang_parser.pycoyml_parser.py
v1.0 Markdown 解析把 user/bot 的样例语料抽成意图/话术colang/v1_0/lang/comd_parser.py
v2.x 解析器lark 文法解析成语法树colang/v2_x/lang/parser.py
v2.x 转换器语法树 → 强类型 AST(Flow/SpecOp)colang/v2_x/lang/transformer.py
v2.x AST 定义所有语法节点的数据类colang/v2_x/lang/colang_ast.py

本章的边界: 上图里"运行时消费"以下不讲。本章只走到"文本 .co → 结构化 flow" 这一步为止。flow 怎么被状态机一步步执行,是 第 03 章


3. 核心心智模型:canonical form / user intent / bot intent

要读懂 Colang(尤其 1.0),必须先装上一套心智模型。它是整个对话系统的地基。

3.1 三个概念

对话被拆成三层抽象:

概念白话例子
user intent(用户意图)用户这句话"想表达的意思",而非原话express greeting
bot intent(机器人意图)机器人"想回应的意思"express greeting
canonical form(规范形式)意图的标准名字——一串小写短语,当"锚点"express greetingask about weather

关键直觉: 用户可以有一万种说法("hi"、"hello"、"yo"),但它们都被归一到同一个 canonical form express greeting。护栏逻辑不去匹配千变万化的原话,而是匹配这个稳定的 意图锚点。这就是为什么护栏能可靠地"认出"用户在干嘛。

用户原话(千变万化) canonical form(唯一锚点) 机器人反应
"hi" ─────────┐
"hello" ──────┼──▶ user express greeting ──▶ bot express greeting
"yo there" ───┘ │
"Hi there!"

3.2 在语言里怎么体现

v1.0 里,define user 定义"哪些原话归到某个意图",define bot 定义"某个意图对应哪些话术", define flow 把它们串成剧本:

define user express greeting # 这些原话都算"打招呼"这个意图
"hi"
"hello"

define bot express greeting # 机器人"打招呼"意图的话术
"Hey there!"

define flow greeting # 剧本:匹配到用户打招呼,就让机器人打招呼
user express greeting
bot express greeting

解析器在读到 define user 时,把符号类型标成 intent;读到 define bot/define template 标成 utterance(colang/v1_0/lang/colang_parser.py:760-769)。而"这些原话"这些语料 则走 Markdown 解析(comd_parser.py,parse_patterncomd_parser.py:26)被抽成训练样例, 供后续做意图相似度匹配。

3.3 flow 如何成为 rail 的执行单元

一条护栏(rail)在配置里就是一串 flow 名字(见 第 01 章)。 运行时激活某条 rail,本质是激活它引用的那些 flow。所以:

flow = 护栏的最小可执行单位。 配置说"跑这条 input rail",引擎就去找同名 flow, 把它当剧本一步步演。一个 flow 内部的 match/if/bot/abort 就是护栏的控制流。


4. Colang 1.0:多级转译的"意图语言"

v1.0 的解析不是一步到位,而是层层降级转译:先把宽松的口语化语法逐行"规范化", 再转成中间格式,最后展开成一张线性的"元素表"。理解这条流水线,就理解了 v1.0。

4.1 流水线全貌

.co 文本
│ ① 逐行正则规范化(把口语糖变成标准写法)

规范化的行 ── ColangParser.parse()
│ ② 按主 token 分派到 _parse_user / _parse_bot / _parse_if ...

CoYML(嵌套 dict/list 的"对话 YAML")
│ ③ parse_flow_elements:展平成线性元素 + 解析 goto

CIL 元素表(带 _type 的扁平指令序列,给引擎执行)

三步分别落在:parse_colang_file(v1_0/lang/parser.py:60)串起全流程,内部先调 parse_coflows_to_yml_flows,再对每个 flow 调 parse_flow_elements (v1_0/lang/parser.py:71-74)。

4.2 第①步:逐行正则规范化(口语糖 → 标准形)

v1.0 语法故意"宽松好写",代价是解析器要先做大量正则改写,把各种简写拉平成一种标准形。 这一步在 ColangParser._normalize_line_text(colang_parser.py:169)。举几个真实规则:

你写的被改写成含义
user says hiuser hi去掉 says 语气词
execute foorun foo统一动作关键字
$x = foo(...)(run 上下文)run $x = foo(...)补上缺失的 run
def flowdefine flowdefdefine 简写
user somethinguser *something 变通配

改写后才提取"主 token"(每行第一个关键词),合法主 token 的白名单写死在 VALID_MAIN_TOKENS(colang_parser.py:38),涵盖 user/bot/flow/if/when/run/ abort 等。

4.3 第②步:按主 token 分派解析

ColangParser.parse()(colang_parser.py:1607)是主循环:逐行取主 token, switch 到对应的 _parse_* 方法。核心几个:

  • _parse_user(:1028)—— 解析用户意图行,支持 user X as $varuser A or B(转成 any)。
  • _parse_bot(:1111)—— 解析机器人话术,可带 id、样例、参数,是最繁的一支。
  • _parse_if / _parse_when / _parse_while —— 分支与事件分支,维护 branches/ifs 栈处理缩进嵌套。

4.4 第③步:CoYML → CIL,把树展平成线性指令

_dict_to_element(coyml_parser.py:81)是转译的心脏:把每个简写 dict 翻成带 _type 的标准元素。例如 user express greeting 变成一个 UserIntent 元素 (coyml_parser.py:112-159),bot ... 变成 run_actionutter (coyml_parser.py:173)。

然后 _extract_elements(coyml_parser.py:404)把嵌套结构展平:if/elsewhile、 分支都被转成"元素 + 相对跳转(jump)"的线性序列——这正是运行时那台状态机能直接吃的格式。 goto/label 最后由 _resolve_gotos 解析成相对跳转。

精华: v1.0 把"高层对话语法"一路降级成"带跳转的扁平指令表",很像编译器把 结构化代码编译成带跳转的字节码。运行时因此不必懂 if,只需懂"执行元素 + 跳转"。


5. Colang 2.x:lark 文法驱动的"事件语言"

2.x 是一次重写。它不再靠一堆正则,而是有正式文法(lark 语法文件),解析成强类型 AST;心智模型也从"意图匹配"转向更显式的"事件的 match / send / await"。

5.1 解析链:文法 → 语法树 → AST

.co 文本
│ ① 预展开("..." 之类,parser.py:_apply_pre_parsing_expansions)

lark 解析(colang.lark 文法) parser.py:52 get_parsing_tree

lark 语法树
│ ② ColangTransformer 遍历,建强类型 AST

Flow / SpecOp / If / When ... 数据类 colang_ast.py

ColangParser.parse_content(v2_x/lang/parser.py:97)先跑预展开,再用 lark 解析成树, 最后交给 ColangTransformer(transformer.py:50)转成 AST。文法本体在 v2_x/lang/grammar/colang.lark,flow_def 规则定义了一个 flow 的骨架: decorators + _FLOW spec_name + 参数 + suite(缩进块)。

5.2 spec 与 SpecOp:2.x 的核心抽象

2.x 把 flow、action、event 统一成一个概念叫 spec(colang_ast.py:179Spec), 在其上做操作(SpecOp)。合法操作只有七种(colang_ast.py:231SpecOpType):

操作含义
match等待某事件发生(阻塞直到匹配)
send发出一个事件
start启动一个 flow/action(不等它完成)
await启动并等待完成(= start + match Finished)
stop / activate / deactivate停止 / 激活 / 停用

一行如 match UtteranceUserAction.Finished(...) 被转成一个 SpecOp(op="match", spec=...) (transformer.py:208_spec_op)。若一行没写操作符,默认是 await (transformer.py:218)。

spec 是什么类型靠命名约定推断(transformer.py:318-329):名字全小写 → 当 flow; 以 Action 结尾 → 当 action;其余 → 当 event。所以 user said 是 flow, ContentSafetyCheckInputAction 是 action,StartFlow 是 event。

5.3 await 是语法糖:留给运行时展开

await X 只是简写。在展开阶段(v2_x/lang/expansion.py:480_expand_await_element), 它被拆成两步:start X + match X.Finished()——先启动,再等它报完成 (expansion.py:496-512)。这层展开是语言层和运行时的接缝;再往下的执行(状态机、 多 head 并发)是 第 03 章 的内容,本章不展开。

5.4 同一护栏,两版对照

内容安全护栏同时有 v1 和 v2 两份实现,并排看最能体会语言演进 (library/content_safety/flows.coflows.v1.co):

# ---- v2.x(flows.co)----
flow content safety check input $model
$response = await ContentSafetyCheckInputAction(model_name=$model)
global $allowed
$allowed = $response["allowed"]

if not $allowed
if $system.config.enable_rails_exceptions
send ContentSafetyCheckInputException(message="Input not allowed. ...")
else
bot refuse to respond
abort

对照 第 1 节 里的 v1 版本,能看出几处关键差异:

方面v1.0v2.x
定义 flowdefine flow Xflow X(带参数 $model)
调动作execute content_safety_check_inputawait ContentSafetyCheckInputAction(...)
发事件create event ...send ...Exception(...)
掐断stopabort(AST 里是 Abort,colang_ast.py:313)
全局变量隐式显式 global $allowed
配置访问$config.xxx$system.config.xxx

注意 bot refuse to respond: 它不是内置关键字,而是另一个 flow 的名字 (colang/v2_x/library/core.co:174 定义了 flow bot refuse to respond)。 一个 flow 通过写下另一个 flow 的名字来调用它——这正是"flow 是可组合执行单元"的体现。


6. 教学示例:手写一个 flow

下面手写一个极简的输入护栏 flow,把本章概念串起来。它的意思是:等用户说了话 → 调一个自定义检查动作 → 命中敏感词就拒绝并掐断。写的是 v2.x 风格。

# 示意教学 flow(v2.x 风格),演示 match / await / send / abort 四类操作
flow block secret topic
# ① match:阻塞,直到用户说了某句话,把事件捕获到 $said
user said something as $said

# ② await:启动并等待一个检查动作完成,拿回结果
$result = await CheckSecretTopicAction(text=$said.transcript)

# ③ if 分支:命中就走拒绝
if $result["blocked"]
bot refuse to respond # 调用库里已定义的另一个 flow
abort # ④ abort:掐断当前对话流,阻止后续

重点看: 这段脚本里没有一行"传统控制流原语"——user saidawaitbot refuse 都是对话领域的操作。解析器会把它变成一串 SpecOp(user said → match、 CheckSecretTopicAction → await→start+match、send/abort 各自的 AST 节点), 交给运行时演出。护栏工程师写的,始终是"剧本"这一层。

对应的 AST(概念示意)

解析后,上面的 flow 大致对应这样一棵强类型树(字段名取自 colang_ast.py):

Flow(name="block secret topic") # colang_ast.py:155
├─ SpecOp(op="match", spec=user said ...) # :246
├─ SpecOp(op="await", spec=CheckSecretTopicAction ...)
└─ If(expression='$result["blocked"]', # :272
then_elements=[ SpecOp(op="await", spec=bot refuse to respond),
Abort() ]) # :313

到这里,.co 文本已经变成了一棵机器能精确执行的树。它怎么被一步步执行,请转 第 03 章:事件驱动运行时


7. 边界与局限(本章刻意不讲什么)

  • 不讲运行时执行。 flow 被解析成元素/AST 之后,状态机如何推进、多 head 如何并发、 事件如何流转,全在 第 03 章。本章止于"文本 → 结构"。
  • 不讲 LLM 如何把原话映射到意图。 canonical form 的相似度匹配、prompt 组装属于 第 05 章:LLM 与 prompt 层
  • 两版不可混用。 同一份配置只能是 1.0 或 2.x,靠 _is_colang_v2 嗅探 (colang/__init__.py:68);parse_flow_elements 对 2.x 直接 NotImplementedError (colang/__init__.py:62-63),因为 2.x 走完全不同的 AST 路径。
  • v1.0 解析器"宽松"是双刃剑。 大量正则改写让语法好写,但也让边角写法的行为难以 一眼预测——行为不确定时,读 _normalize_line_text 的规则表(colang_parser.py:186-233) 或跑测试(tests/colang/parser/)是最可靠的。

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

主题文件路径符号名
版本分派入口nemoguardrails/colang/__init__.pyparse_colang_file
1.0/2.x 版本嗅探nemoguardrails/colang/__init__.py_is_colang_v2
v1.0 顶层解析nemoguardrails/colang/v1_0/lang/parser.pyparse_colang_file
v1.0 主解析器nemoguardrails/colang/v1_0/lang/colang_parser.pyColangParserparse
v1.0 行规范化(口语糖)nemoguardrails/colang/v1_0/lang/colang_parser.py_normalize_line_textVALID_MAIN_TOKENS
v1.0 user/bot 解析nemoguardrails/colang/v1_0/lang/colang_parser.py_parse_user_parse_bot_process_define
v1.0 CoYML→CIL 转译nemoguardrails/colang/v1_0/lang/coyml_parser.py_dict_to_element_extract_elementsparse_flow_elements
v1.0 意图/话术语料解析nemoguardrails/colang/v1_0/lang/comd_parser.pyparse_patternparse_md_file
v2.x 解析入口nemoguardrails/colang/v2_x/lang/parser.pyColangParserparse_content
v2.x 文法nemoguardrails/colang/v2_x/lang/grammar/colang.larkflow_defspec_operator
v2.x 语法树→ASTnemoguardrails/colang/v2_x/lang/transformer.pyColangTransformer_flow_def_spec_op_spec
v2.x AST 数据类nemoguardrails/colang/v2_x/lang/colang_ast.pyFlowSpecSpecOpSpecOpTypeIfWhenAbort
v2.x await 展开nemoguardrails/colang/v2_x/lang/expansion.py_expand_await_elementexpand_elements
真实护栏 flow(两版对照)nemoguardrails/library/content_safety/flows.coflows.v1.coflow content safety check input
v2.x 标准库 flownemoguardrails/colang/v2_x/library/core.coflow user saidflow bot refuse to respond