跳到主要内容

事件驱动运行时与对话生成(核心主线)

30 秒导读: NeMo Guardrails 的对话护栏(dialog rail)不是"调一次 LLM 拿答案"。 它把一次对话建模成一串事件(event),用一个 Colang 状态机反复推进;每当状态机 说"该执行某个动作了",就跳去做三步 LLM 生成——先把用户这句话对到一个已知意图 (canonical form,规范形式),再决定 bot 该做什么,最后才生成措辞。这一章端到端追这条主线。

这是全库最核心的一章。读懂它,你就懂了 NeMo Guardrails "为什么能对对话本身设护栏"。

本章只聚焦一件事:一条请求进来,dialog rail 的运行时怎么转、三步生成怎么算。


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

一句话定义

对话护栏运行时 = 一个"事件循环 + 状态机 + 三步 LLM 生成"的组合,它决定"用户说了这句话之后, bot 到底该回什么、要不要被拦"。

它要解决的问题

假设你想给客服机器人加一条规则:"只要用户问薪资,就别正面答,改成引导去 HR 系统"。

  • 如果你只有一个大 LLM,你只能把这条规则塞进 system prompt,然后祈祷模型听话。
  • NeMo Guardrails 换了个思路:先强制模型把用户这句话归类成一个有名字的意图 (比如 ask about salary),然后用一段你写死的对话流程(Colang flow)决定接下来干嘛。 规则不再飘在 prompt 里,而是落到了状态机的一条确定路径上

核心直觉:把对话拆成"意图"而不是"文本"

模型每一步只需回答一个受约束的小问题,而不是自由发挥:

步骤模型被问的问题输出(受约束)
第 1 步"用户这句话,最接近哪个已知意图?"一个 canonical form,如 ask about salary
第 2 步"面对这个意图,bot 该做什么?"一个 bot intent,如 refuse to answer
第 3 步"把这个 bot intent 变成一句人话"最终措辞

关键: 第 1、2 步的候选项都来自你在配置里写好的意图和流程,靠 embedding 相似度 从库里捞出来做 few-shot 例子。所以模型不是凭空分类,而是"从你给的选项里挑"。

用起来什么样

一个最小的 Colang 配置长这样(这就是你写的"规则"):

# 已知的用户意图(会被塞进 embedding 索引)
define user ask about salary
"how much do you pay"
"what is the salary"

# 一条对话流程:看到这个意图,就走这条固定路径
define flow
user ask about salary
bot refuse to discuss salary

define bot refuse to discuss salary
"I can't discuss salary here. Please check the HR portal."

运行时你调 rails.generate(messages=[{"role": "user", "content": "yo what's the pay like"}]), 即使用户用了完全不同的措辞,运行时也会:把 "yo what's the pay like" 对到 ask about salary → 状态机匹配到那条 flow → 决定 bot intent 是 refuse to discuss salary → 输出你写死的那句话。

本节不碰底层。记住这个心智模型即可:文本 → 意图 → 状态机路径 → 措辞。


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

主线的五个关口

从 Python 调用到最终事件,一条请求穿过这几层。从上到下就是调用栈:

rails.generate_async() rails/llm/llmrails.py:927
│ 把 messages 转成事件(带跨轮缓存)

_get_events_for_messages() rails/llm/llmrails.py:735
│ ["UtteranceUserActionFinished", ...]

runtime.generate_events(events) colang/v1_0/runtime/runtime.py:123
│ ┌──────── 事件循环:反复推进直到出现 "Listen" ────────┐
│ │ 看最后一个事件是什么类型: │
│ │ • StartInternalSystemAction → _process_start_action │ ← 这里跳去做 LLM 生成
│ │ • 其它 → _compute_next_steps │ ← 这里推进状态机
│ └───────────────────────────────────────────────────┘

_compute_next_steps() ─→ compute_next_steps() colang/v1_0/runtime/flows.py:570
│ Colang 状态机:滑动所有 flow,算出"下一步"

下一步可能是 "execute generate_user_intent"


_process_start_action() ─→ 三步 LLM 生成 actions/llm/generation.py
① generate_user_intent (识别用户意图) :351
② generate_next_steps (决定 bot 意图) :600
③ generate_bot_message (生成措辞) :757

部件一句话职责

部件干什么在哪
LLMRails.generate_async对外入口,编排整条管线rails/llm/llmrails.py:927
_get_events_for_messages把聊天 messages 翻译成事件流,复用历史缓存rails/llm/llmrails.py:735
RuntimeV1_0.generate_events事件循环:推进到出现 Listen 为止colang/v1_0/runtime/runtime.py:123
_compute_next_steps让状态机滑动一步,产出下一批事件colang/v1_0/runtime/runtime.py:202
compute_next_steps / compute_next_stateColang 状态机本体colang/v1_0/runtime/flows.py:570 / :317
_process_start_action真正执行一个动作(含三步生成动作)colang/v1_0/runtime/runtime.py:564
LLMGenerationActions三步 LLM 生成动作的实现actions/llm/generation.py:351/600/757
BasicEmbeddingsIndexcanonical form 的 embedding 匹配embeddings/basic.py:32

主线走一遍(高层)

  1. 用户消息 → 变成 UtteranceUserActionFinished 事件。
  2. 事件循环开始转。默认 flow process user inputrails/llm/llmrails.py 加载的 llm_flows.co) 产出 UserMessage 事件,接着触发 run dialog rails
  3. run dialog rails 里写着 do generate user intent,展开成 execute generate_user_intent → 变成一个 StartInternalSystemAction 事件。
  4. 循环看到 StartInternalSystemAction,跳进 _process_start_action执行第 ① 步生成, 产出一个 UserIntent 事件。
  5. 循环继续。状态机现在能匹配 flow 了,若没有现成 flow 决定下一步,就触发 generate next step第 ② 步,产出 BotIntent
  6. BotIntent 又触发 generate bot message flow → 第 ③ 步,产出 BotMessage / StartUtteranceBotAction
  7. 没有更多可推进的 → 循环产出 Listen 事件 → generate_asyncStartUtteranceBotAction 里的话拼成最终回复。

3. 核心原理(逐个机制,由浅入深)

3.1 事件循环:一切都是事件,推进到 Listen 为止

它要解决的小问题: 一次对话里,"执行动作 → 拿到结果 → 再决定下一步"要来回好几轮, 而且每轮可能触发新动作。需要一个能反复自我推进、又知道何时该停下来等用户的引擎。

思路: 把所有东西都表示成事件 dict({"type": "UserMessage", "text": ...}), 然后写一个 while True 循环:每轮看最后一个事件是什么,决定这轮做什么,产出的新事件 追加回事件流,直到出现 Listen("轮到用户说话了")。

真实实现: RuntimeV1_0.generate_eventscolang/v1_0/runtime/runtime.py:123)。 循环的分派逻辑很直白——

# 引自 runtime.py:147-166,删了日志
while True:
last_event = events[-1]
if last_event["type"] == "StartInternalSystemAction":
next_events = await self._process_start_action(events) # 去执行动作(含 LLM 生成)
elif last_event["type"] == "start_flow" and last_event.get("flow_body"):
next_events = await self._process_start_flow(events, ...)
else:
next_events = await self._compute_next_steps(events, ...) # 推进状态机
if len(next_events) == 0:
next_events = [new_event_dict("Listen")]

三条分支的含义:

  • StartInternalSystemAction → 有个动作要执行(比如三步生成之一),去 _process_start_action
  • start_flow(带 body) → 动态注入一段 Colang(多步生成时会用到)。
  • 其它 → 让状态机滑动一步,算下一步是什么。

停机条件与安全阀runtime.py:184-189):

if next_events[-1]["type"] == "Listen":
break
if len(new_events) > 300:
raise Exception("Too many events.")

Listen 表示"没有更多能推进的了,等用户"。> 300 是防死循环的硬上限。

关键细节: 状态机从不在 StartInternalSystemAction 上前进——它等的是动作完成后InternalSystemActionFinished(见 compute_next_stateflows.py:335-337)。 也就是说"决定要执行动作"和"动作执行完了"是两个事件,中间那次真正的 LLM 调用发生在 _process_start_action 里。

3.2 Colang 状态机:滑动所有 flow,选出"下一步"

它要解决的小问题: 你写了一堆 Colang flow(每条是"用户 X → bot Y"的脚本)。运行时怎么知道 当前这个事件让哪条 flow 前进了、下一步该干嘛?

思路: 把每条 flow 看成一个"读头"(head)指向某个元素的小程序。来一个事件,就试着让每条 活跃 flow 的读头往前滑——能匹配就前进,不能匹配就中断或放弃。所有 flow 处理完,挑一个 优先级最高的、指向"可执行元素"的 flow,它指的那步就是下一步

图示(一条 flow 的推进):

flow: user ask about salary → bot refuse
head=0 head=1

事件 UserIntent("ask about salary") 到来
│ _is_match(head=0 元素, 事件) == True

head 前进到 1(bot refuse),这是可执行步


next_step = {"bot": "refuse ..."} → 产出 BotIntent 事件

真实实现: 状态机是纯函数式的——compute_next_stepsflows.py:570)对历史里每个事件 依次调 compute_next_stateflows.py:317),后者做三件事:

  1. 尝试推进已有 flow:对每个 flow_state,看当前事件能否匹配读头元素 (_is_matchflows.py:404-408),能就 flow_state.head = matching_head 并滑过 subflow。
  2. 尝试启动新 flowflows.py:432 起的循环)。
  3. 记录优先级最高的下一步_record_next_step)。

FlowState 就是"哪条 flow、读头在第几个元素、什么状态"(flows.py:77):

@dataclass
class FlowState:
uid: str
flow_id: str
head: int # 读头位置
status: FlowStatus = FlowStatus.ACTIVE # ACTIVE / INTERRUPTED / ABORTED / COMPLETED

关键细节 / 坑:

  • 这是官方自称"简化版 CoFlows 引擎"(flows.py:16:320):所有 flow 是单例、 一次只能被一条 flow 打断、无真正的优先级仲裁("第一个能决定的就用它")。复杂场景是 Colang 2.x 的目标。
  • 匹配不上时,flow 不是简单丢弃:在可执行元素上匹配失败会 ABORTED,否则 INTERRUPTEDflows.py:422-426),留待后续恢复。
  • generate next step 这条 flow 的优先级被特意设成 0.9llm_flows.co:78),低于默认的 1.0——意思是"如果有你自己写的 flow 能决定下一步,就用你的;实在没有,才让 LLM 现编下一步"。

3.3 三步 LLM 生成(对话护栏的心脏)

这是整章的重点。当状态机说"该 execute generate_user_intent 了",_process_start_actionruntime.py:564)通过 action dispatcher 找到并调用对应方法。三步依次登场。

图示:三步各吃什么、吐什么

用户文本 "yo what's the pay like"

▼ ① generate_user_intent Task.GENERATE_USER_INTENT
│ embedding 捞相似示例 → LLM 归类

UserIntent(intent="ask about salary")

▼ ② generate_next_steps Task.GENERATE_NEXT_STEPS
│ embedding 捞相似 flow → LLM 定 bot 动作

BotIntent(intent="refuse to discuss salary")

▼ ③ generate_bot_message Task.GENERATE_BOT_MESSAGE
│ 命中预定义消息就直接用;否则 LLM 生成措辞

BotMessage(text="I can't discuss salary here...")

三步对应的 Task 枚举都在 llm/types.py:24-26,是 prompt 渲染的 key(详见 05-llm-and-prompting-layer.md):

GENERATE_USER_INTENT = "generate_user_intent"
GENERATE_NEXT_STEPS = "generate_next_steps"
GENERATE_BOT_MESSAGE = "generate_bot_message"

第 ① 步 · generate_user_intent —— 把自由文本对到已知意图

generate_user_intentactions/llm/generation.py:351)核心流程:

  1. 取最后一条用户话(get_last_user_utterance_event)。

  2. embedding 检索:在 user_message_index 里搜最相似的 5 个已知 utterance,把它们连同各自的 intent 组成 few-shot 例子(generation.py:410-415):

    results = await self.user_message_index.search(text=text, max_results=5, threshold=None)
    for result in reversed(results): # 逆序:最相关的放最后(离答案最近)
    examples += f'user "{result.text}"\n {result.meta["intent"]}\n\n'
  3. 渲染 GENERATE_USER_INTENT prompt(带上例子和 potential_user_intents 列表), temperature=0 调 LLMgeneration.py:429-435,注释明说"尽量确定")。

  4. 解析输出,取第一行非空、剥掉开头的 user ,得到 canonical form,产出 UserIntent(intent=...) 事件。

embeddings_only 捷径: 如果配了 embeddings_only,且相似度过了阈值,根本不调 LLM, 直接用最相似那条的 intent(generation.py:399-408)——省一次 LLM 调用:

if config.rails.dialog.user_messages.embeddings_only:
threshold = config.rails.dialog.user_messages.embeddings_only_similarity_threshold
results = await self.user_message_index.search(text=text, max_results=5, threshold=threshold)
if results:
intent = results[0].meta["intent"]
return ActionResult(events=[new_event_dict("UserIntent", intent=intent)])

没有 user_messages 配置时:else 分支——退化成普通 LLM 生成(passthrough / general), 直接出 BotMessage,不玩意图那套(generation.py:454-578)。

第 ② 步 · generate_next_steps —— 决定 bot 该做什么

generate_next_stepsgeneration.py:600)在没有现成 flow决定下一步时才被调 (回忆 3.2 里那条 priority 0.9 的兜底 flow)。它:

  1. 取最后的 UserIntentget_last_user_intent_event)。
  2. embedding 检索 flow_search_flows_index(text=user_intent, max_results=5),把相似 flow 当例子(generation.py:629-633)。
  3. 渲染 GENERATE_NEXT_STEPS prompt,同样 temperature=0 调 LLM(generation.py:644-650)。
  4. 默认(非 multi-step)只看第一行,剥掉 bot 得到 bot intent,产出 BotIntent 事件 (generation.py:657-689);否则把整段当一条动态 Colang flow,用 start_flow 事件注入 (generation.py:690-717,这也是 3.1 里 start_flow 分支的来源)。

第 ③ 步 · generate_bot_message —— 出话术

generate_bot_messagegeneration.py:757)拿到 BotIntent 后决定措辞,优先级从高到低:

情况行为代码
bot intent 有预定义消息bot_messages 里随机选一条(测试模式取第一条),渲染变量,跳过 output railgeneration.py:783-797
bot intent 形如 $var 且在 context直接取 context 变量值generation.py:800-801
都不是LLM 生成:捞相似 bot 消息做例子,渲染 GENERATE_BOT_MESSAGE prompt 调 LLMgeneration.py:803

关键细节: 预定义消息路径会设 context_updates["skip_output_rails"] = Truegeneration.py:797)——你写死的话不再过输出护栏。流式输出场景也会因单独跑输出 rail 而设这个标志(generation.py:780-781)。输出 rail 的执行细节见 04-input-output-rails-and-library.md

3.4 embedding 驱动的 canonical form 匹配

它要解决的小问题: 用户会用一万种说法表达同一个意思。你不可能把所有说法都写进配置。 怎么把 "yo what's the pay like" 稳稳对到你只写了两三个例句的 ask about salary

思路: 把配置里每个已知 utterance 编码成向量存进索引;来一句新话也编码成向量,找余弦最近的 几条,它们的 intent 就是候选答案(要么直接用,要么当 few-shot 喂给 LLM)。

三个索引,同一套机制: 三步生成各自建一个 embedding 索引,都在 LLMGenerationActions 里 懒加载(generation.py):

索引内容(IndexItem)建于
user_message_index每个已知 utterance → meta={intent}_init_user_message_index :205
bot_message_index每个 bot intent → meta={text}_init_bot_message_index :229
flows_indexflow 的每一行(实验性)→ meta={flow}_init_flows_index :253

注意 flows 索引的巧处:不是整条 flow 编码,而是按行建条目(generation.py:273-276), 这样"用户意图行"和"bot 动作行"都能被单独命中。检索后再去重成整条 flow(_search_flows_index, generation.py:580-597)。

真实实现: 默认索引是 BasicEmbeddingsIndexembeddings/basic.py:32), 默认模型 sentence-transformers/all-MiniLM-L6-v2basic.py:53)。它做的是精确余弦最近邻, 不是近似(basic.py:36-37):

  • build() 把所有 embedding 存成一个 L2 归一化的 numpy 矩阵(basic.py:212-217)。

  • search() 一次矩阵乘法算出对每一行的余弦相似度(basic.py:321):

    # basic.py:315-321,query 也归一化
    query = query / query_norm
    cosine = self._index @ query # 归一化后点积 == 余弦相似度
  • 妙处:它把余弦换算回旧 Annoy 的角度距离分数basic.py:323-331),这样老配置里的 similarity_threshold 数值语义不变——换了后端而不破坏用户阈值。

关键细节: searchnp.argpartition 取 top-k 再排序(basic.py:335-336), 对大索引比全排序快。阈值为 inf(默认)时不过滤,全返回 top-k(basic.py:339-344)。 其它可选后端(openai / fastembed / nim / cohere / google / azure)见 embeddings/providers/__init__.py:62-69,都实现同一个 EmbeddingModel 接口。

3.5 single-call 优化:三步并一步

它要解决的小问题: 三步 = 三次 LLM 往返,慢、贵。多数情况下这三步的答案其实高度相关—— 能不能一次问出来?

思路: 让 LLM 在一次调用里同时吐出:用户意图行、bot 意图行、bot 消息。开关是 config.rails.dialog.single_call.enabledrails/llm/config.py:836-843SingleCallConfig, 默认 False)。

真实实现: 开了以后,generate_user_intent 第一件事就转发到 generate_intent_steps_messagegeneration.py:361-362):

if self.config.rails.dialog.single_call.enabled:
return await self.generate_intent_steps_message(events=events, llm=llm, kb=kb)

generate_intent_steps_messagegeneration.py:1109)一次 LLM 调用后,解析出前两行非空 (用户意图、bot 意图)和剩下的 bot 消息(generation.py:1305-1338),然后把后两步的结果 预先塞进 UserIntent 事件的 additional_infogeneration.py:1344-1350):

additional_info = {
"bot_intent_event": new_event_dict("BotIntent", intent=bot_intent),
"bot_message_event": new_event_dict("BotMessage", text=bot_message),
}
events = [new_event_dict("UserIntent", intent=user_intent, additional_info=additional_info)]

于是后续第 ② / ③ 步不再调 LLM,直接从 additional_info 取现成结果:

  • generate_next_steps 里:bot_intent_event = event["additional_info"]["bot_intent_event"]generation.py:618-620)。
  • generate_bot_message 里:取 bot_message_event,还会校验它对应的 bot intent 与当前一致 (generation.py:807-822)。

关键细节: single-call 的 few-shot 例子构造更复杂——它对每个候选 intent 再去 flows 索引里 找关联 flow、再去 bot 消息索引找关联措辞,拼成"意图 + 续写 + 消息"三合一的例子 (generation.py:1164-1230)。它也支持流式(在拿到前两行后即开始 pipe 输出,generation.py:1254-1283)。 fallback_to_multiple_callsconfig.py:840)控制单次失败时是否退回三步。


4. 深入实现:一个事件的完整生命周期

把前面串起来,追一条 "how much do you pay" 从入到出(非 single-call、有匹配 flow):

#事件流最后一个事件循环分支产出
1UtteranceUserActionFinished_compute_next_stepsprocess user input flow 推进 → UserMessage
2UserMessage_compute_next_stepsrun dialog railsStartInternalSystemAction(generate_user_intent)
3StartInternalSystemAction_process_start_action① LLMUserIntent("ask about salary")
4UserIntent_compute_next_steps你的 flow 匹配 → 下一步是 bot refuseBotIntent
5BotIntent_compute_next_stepsgenerate bot message flow → StartInternalSystemAction(generate_bot_message)
6StartInternalSystemAction_process_start_action 命中预定义 → BotMessage / StartUtteranceBotAction
7StartUtteranceBotAction_compute_next_steps无更多推进 → Listen
8Listen循环 break

注意第 4 步:因为你写了 flow,状态机自己就决定了下一步,根本没触发第 ② 步的 LLM。 第 ② 步只在"没有现成 flow"时靠那条 priority 0.9 的兜底 flow 才登场(见 3.2)。这就是 "你写的规则越全,LLM 被调用得越少、越可控"的机制体现。

动作怎么被真正调用: _process_start_actionruntime.py:564-648)从事件里取 action_name,用 action_dispatcher.get_action 找到函数,按签名注入 events / context / config / llm_task_manager 等参数(runtime.py:627-642),执行,把返回打包成 InternalSystemActionFinished + 若干 ContextUpdate 事件(runtime.py:668-683)。 状态机就是靠这个 Finished 事件才继续前进的(呼应 3.1 的关键细节)。

跨轮缓存: _get_events_for_messagesllmrails.py:735)会找 messages 的最长前缀, 若命中 events_history_cache 就复用那些事件——避免为历史轮次重跑昂贵的多次 LLM 生成 (llmrails.py:756-765)。只有新增的消息才现转成事件。


5. 巧妙之处(可借鉴的技术)

  • "文本 → 意图 → 状态机"三级降维。 把"模型该说什么"这个开放问题,拆成两个从候选里挑的 受约束子问题 + 一个措辞问题。可控性来自"候选来自你的配置"。核心在 generate_user_intent / generate_next_stepsgeneration.py:351/600)。

  • 兜底 flow 的优先级 0.9。 用一个比默认低的优先级,实现"能用你写的就用你的,实在没有才让 LLM 现编",无需额外 if/else 判空。llm_flows.co:72-81

  • embedding 逆序拼例子。 few-shot 例子按相似度逆序排(最相关的贴着 prompt 末尾), 利用 LLM"近因效应"提升命中。generation.py:411-415

  • 换后端不破坏阈值。 把精确余弦换算回旧 Annoy 分数,让历史配置的 similarity_threshold 语义不变。embeddings/basic.py:323-331

  • single-call 把后两步结果寄生在事件里。 一次 LLM 出三样,把 bot intent / message 塞进 UserIntent.additional_info,让后续步骤零 LLM 复用。generation.py:1344-1350

  • embeddings_only 完全跳过 LLM。 高置信度分类根本不必调模型。generation.py:399-408

  • flow 按行建索引。 让"用户意图行"和"bot 动作行"都能被独立检索命中,再去重成整条 flow。 generation.py:273-276:580-597


6. 边界与局限(诚实)

  • 这是 Colang 1.0 的简化状态机。 官方自述(flows.py:16/320):flow 单例、一次只能被一条 flow 打断、无真正优先级仲裁、"第一个能决定的就赢"。复杂并发对话是 Colang 2.x 的目标(本章不覆盖 2.x 的 RuntimeV2_x.process_events,那是另一条分支,llmrails.py:1077-1095)。

  • 事件循环有 300 事件硬上限runtime.py:188),复杂 flow 组合可能撞上 Too many events.

  • 对话护栏强依赖你写的意图/flow 质量。 没写 user_messages,第 ① 步就退化成普通 LLM 生成、 不再有意图约束(generation.py:454)。embedding 匹配也只在你给了足够例句时才准。

  • multi-step 生成会动态解析 LLM 产出的 Colanggeneration.py:690-717),解析失败就逐行削减 直至退回 general response——即"模型现编的流程语法错了就降级"。

  • 单次 LLM 调用无法自纠。 single-call 用 temperature=0 换确定性,但注释也承认"对 canonical form 好,对 bot 措辞不是好选择"(generation.py:1251-1252)。


7. 横向对比

同 shelf 里其它 agent 框架多把"该说什么"整段交给 LLM + 工具循环。NeMo Guardrails 的独特取舍是 在对话层引入一个显式状态机,强迫每步先归约到有名字的 intent,再走确定路径。代价是要写 Colang、 要维护意图库;收益是对话行为可审计、可约束。这与纯 prompt 式护栏(把规则塞 system prompt)形成 对比——后者简单但不可控。single-call 优化则是它对"状态机式护栏太慢"这一批评的直接回应。


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

主题文件符号
对外入口 / 管线编排rails/llm/llmrails.pyLLMRails.generate_async
messages → 事件 + 跨轮缓存rails/llm/llmrails.py_get_events_for_messages
事件循环(推进到 Listen)colang/v1_0/runtime/runtime.pyRuntimeV1_0.generate_events
让状态机滑动一步colang/v1_0/runtime/runtime.py_compute_next_steps
执行动作(含三步生成)colang/v1_0/runtime/runtime.py_process_start_action
状态机本体(纯函数)colang/v1_0/runtime/flows.pycompute_next_steps / compute_next_state
flow 状态 / 配置colang/v1_0/runtime/flows.pyFlowState / FlowConfig / State
① 识别用户意图actions/llm/generation.pyLLMGenerationActions.generate_user_intent
② 决定 bot 动作actions/llm/generation.pygenerate_next_steps
③ 生成 bot 措辞actions/llm/generation.pygenerate_bot_message
single-call 三合一actions/llm/generation.pygenerate_intent_steps_message
flow embedding 检索actions/llm/generation.py_search_flows_index
三索引懒加载actions/llm/generation.py_init_user_message_index / _init_flows_index
精确余弦最近邻embeddings/basic.pyBasicEmbeddingsIndex.search / build
embedding 后端注册embeddings/providers/__init__.pyregister_embedding_provider
Task 枚举(prompt key)llm/types.pyTask.GENERATE_USER_INTENT
single-call 配置rails/llm/config.pySingleCallConfig / DialogRails
默认对话 flowrails/llm/llm_flows.corun dialog rails / generate next step / generate bot message