外观
流程脚本与 NLU profile 开发规范
本文约定 callout 流程脚本中的 NLU 节点如何声明、实现和验证。目标是避免运营端已保存脚本,但运行时因为 extract_profile / decision_profile 未注册而失败。
核心约定
流程脚本只保存业务编排结构;真正的 NLU 行为由 apps/llm-nlu-server 的 profile 注册表提供。
collect节点使用data.extract_profile,调用统一 NLU 的action=extract。router节点使用data.decision_profile,调用统一 NLU 的action=decision。- profile 是必填的稳定接口 key,必须精确匹配,不做别名回退。
default是能力列表中明确可选的通用策略,不再通过省略字段隐式命中。- 保存时 callout-server 会读取实时 capabilities;profile 未在对应 action 注册或能力服务不可用时拒绝保存。
- 历史脚本中缺失或空白的 profile 会在列表和 runtime 读取时兼容归一化为
default;已有非空值不改写,再次保存后会持久化显式值。
流程图保存契约
- 必须且只能有一个
start;节点 ID 唯一,所有连线端点必须存在。 start、send、transfer必须各有一个默认出口,collect必须有一个success出口。router.branches[*].key必须唯一,并与出边sourceHandle一一对应;不允许隐式默认分支。end不允许出边;所有节点必须从start可达。流程允许形成循环,运行时以最多 100 步防止无限执行。- collect 字段 key、router/transfer
save_as必须合法且不能冲突;模板只能引用已声明变量。 collect.data.extract_profile与router.data.decision_profile必须非空、格式合法,并已在对应 NLU action 注册。- 数据库中的旧脚本若不满足上述契约,运行时接口返回
422,不会替换成空流程继续执行。
当前模板覆盖
当前流程脚本模板使用的 profile:
| 脚本 | 节点 | profile | action | 状态 |
|---|---|---|---|---|
| 场景模板 - 客户资料采集 | collect-profile | customer-profile | extract | 已注册 |
| 场景模板 - 意向分流跟进 | route-intent | sales-intent | decision | 已注册 |
| 场景模板 - 预约回访确认 | collect-appointment | appointment-confirm | extract | 已注册 |
| 场景模板 - 售后满意度回访 | collect-feedback | service-feedback | extract | 已注册 |
| 场景模板 - 售后满意度回访 | route-feedback | service-satisfaction | decision | 已注册 |
| 场景模板 - 投诉安抚与转人工 | collect-complaint | complaint-summary | extract | 已注册 |
| 场景模板 - 投诉安抚与转人工 | route-complaint | complaint-routing | decision | 已注册 |
新增脚本流程
- 设计流程图时,先列出所有需要 NLU 的节点。
- 对每个
collect节点确认字段列表、字段类型、必填项和extract_profile。 - 对每个
router节点确认分支 key、分支描述、save_as和decision_profile。 - 在
apps/llm-nlu-server/src/nlu-registry.ts注册对应 profile。 - 更新
apps/llm-nlu-server/src/handlers.test.ts,至少覆盖 profile 解析与能力发现。 - 更新
apps/llm-nlu-server/README.md的内置 profile 表。 - 通过
/api/v1/nlu/capabilities确认 profile 已出现在对应 action 下。 - 保存流程脚本后,进入
http://localhost:19920/#/flow-scripts打开脚本,确认节点属性与变量目录正常。
profile 命名规范
- 使用小写 kebab-case,例如
appointment-confirm。 - key 应表达业务语义,而不是模型名、页面名或临时任务名。
extract与decision可以存在同名 profile,但它们属于不同 action,语义必须清晰。- 不要把实验性 key 写入生产脚本;需要试验时先用单独脚本和明确前缀。
collect 节点规范
collect 节点必须包含:
prompt:向客户提问的话术。error_prompt:第一次抽取缺必填字段后的补问话术。fields:允许抽取并写入variables的字段白名单。extract_profile:必填的字段抽取策略 key,只能从extract.profiles选择。
字段类型:
| type | 返回值 |
|---|---|
text | 字符串或 null |
number | 数字或 null |
boolean | true / false / null |
date | 日期表达字符串或可归一化日期 |
enum | 必须来自 options |
multi_text | 字符串数组 |
示例:
json
{
"id": "collect-appointment",
"type": "collect",
"data": {
"prompt": "请问您希望哪天、哪个时间段回访?回访主题是什么,是否需要短信提醒?",
"error_prompt": "没有听清,请再说一下回访日期、时间段、主题和是否需要短信提醒。",
"extract_profile": "appointment-confirm",
"fields": [
{
"key": "appointment.date",
"label": "回访日期",
"type": "date",
"required": true,
"description": "客户希望被回访的日期"
},
{
"key": "appointment.slot",
"label": "回访时间段",
"type": "enum",
"required": true,
"options": ["上午", "下午", "晚上"]
},
{
"key": "appointment.sms_reminder",
"label": "短信提醒",
"type": "boolean"
}
]
}
}对应 NLU 请求示例:
json
{
"action": "extract",
"profile": "appointment-confirm",
"text": "下周二下午回访,想聊安装和开票,需要短信提醒",
"fields": [
{ "key": "appointment.date", "label": "回访日期", "type": "date", "required": true },
{
"key": "appointment.slot",
"label": "回访时间段",
"type": "enum",
"required": true,
"options": ["上午", "下午", "晚上"]
},
{ "key": "appointment.sms_reminder", "label": "短信提醒", "type": "boolean" }
]
}期望响应:
json
{
"ok": true,
"data": {
"fields": {
"appointment.date": "下周二",
"appointment.slot": "下午",
"appointment.sms_reminder": true
},
"confidence": 0.86,
"reason": "客户明确说明回访日期、时间段和短信提醒诉求"
}
}router 节点规范
router 节点必须包含:
prompt:用于获取客户意图的问题。error_prompt:意图不清晰时的补问。branches:候选分支,key必须与对应出边sourceHandle一致。decision_profile:必填的路由决策策略 key,只能从decision.profiles选择。save_as:可选,把本轮决策对象写入variables[save_as],便于结果回写或后续模板引用。
示例:
json
{
"id": "route-complaint",
"type": "router",
"data": {
"prompt": "请问这个问题需要我们马上加急处理,还是安排普通跟进?如果您要人工,也可以直接说转人工。",
"error_prompt": "请问是普通跟进、加急处理,还是转人工?",
"decision_profile": "complaint-routing",
"save_as": "complaint.route",
"branches": [
{ "key": "普通跟进", "desc": "客户接受正常时效回访处理" },
{ "key": "加急处理", "desc": "客户明确要求尽快、马上、今天内处理" },
{ "key": "转人工", "desc": "客户明确要求人工坐席或主管介入" }
]
}
}对应连线示例:
json
[
{ "source": "route-complaint", "target": "send-normal", "sourceHandle": "普通跟进" },
{ "source": "route-complaint", "target": "send-urgent", "sourceHandle": "加急处理" },
{ "source": "route-complaint", "target": "transfer-agent", "sourceHandle": "转人工" },
{ "source": "transfer-agent", "target": "end" }
]transfer 节点规范
transfer 节点用于真实 AI 转人工,而不是只播报“稍后联系”。运行时会调用 callflow-esl 中的 requestHumanAgentTransfer,进一步请求 callout-server 的 POST /api/transfers/route 预占空闲坐席,并把客户腿 ctx.conference.join 到 transfer-<uuid> 会议室。
推荐配置:
json
{
"id": "transfer-agent",
"type": "transfer",
"data": {
"prompt": "好的,正在为您转接人工坐席,请稍候。",
"unavailable_prompt": "抱歉,当前没有可用的人工坐席,本次投诉需求已记录,我们会安排专人尽快联系您。",
"ring_timeout_seconds": 30,
"save_as": "complaint.transfer"
}
}执行语义:
routed=true:坐席路由已提交,客户腿加入会议;AI 流程在该节点结束,不再继续执行后续节点。routed=false:保存{complaint.transfer.routed}等结果变量,播报unavailable_prompt,再走默认出边到兜底节点。transfer必须配置默认出边;routed=false时沿该边进入兜底节点。保存时缺少该出口会被拒绝。
对应 NLU 请求示例:
json
{
"action": "decision",
"profile": "complaint-routing",
"text": "这个问题今天必须解决,不行就帮我转人工",
"branches": [
{ "key": "普通跟进", "desc": "客户接受正常时效回访处理" },
{ "key": "加急处理", "desc": "客户明确要求尽快、马上、今天内处理" },
{ "key": "转人工", "desc": "客户明确要求人工坐席或主管介入" }
]
}期望响应:
json
{
"ok": true,
"data": {
"key": "加急处理",
"confidence": 0.84,
"reason": "客户明确要求今天必须解决,优先命中加急诉求"
}
}llm-nlu-server 注册示例
在 apps/llm-nlu-server/src/nlu-registry.ts 中,decision 与 extract 分开注册:
ts
"complaint-routing": {
labelZh: "投诉处理路由",
descriptionZh: "面向投诉安抚场景,识别普通跟进、加急处理或转人工。",
strategy: {
systemPrompt:
"你是投诉处理外呼路由分类器。只能返回 JSON:{\"key\":\"分支key\",\"confidence\":0到1,\"reason\":\"简短原因\"}。必须从给定 branches 的 key 中选择一个。",
ollama: { timeoutMs: 42000 },
fallback: createDecisionFallback("投诉路由关键词兜底"),
fallbackKey: input => fallbackDecision(input.text, input.branches)
}
}ts
"complaint-summary": {
labelZh: "投诉摘要抽取",
descriptionZh: "抽取投诉核心问题、处理诉求和紧急程度。",
strategy: {
systemPrompt:
"你是投诉摘要字段抽取器。只能返回 JSON:{\"fields\":{\"字段key\":值},\"confidence\":0到1,\"reason\":\"简短原因\"}。只允许返回请求 fields/field 中指定的字段 key。",
ollama: { timeoutMs: 45000 },
fallback: createExtractFallback("投诉摘要原文兜底"),
fallbackFields: input => fallbackExtractFields(input.text, input.fields)
}
}验证命令
bash
bun test apps/llm-nlu-server/src/handlers.test.ts
bun run --cwd apps/llm-nlu-server typecheck
curl http://127.0.0.1:9930/api/v1/nlu/capabilities如果通过前端页面验证,打开 http://localhost:19920/#/flow-scripts,选择目标脚本后检查:
- 节点属性里的 profile key 与注册表一致。
router分支 key 与画布出边sourceHandle一致。collect字段 key 只包含字母、数字、下划线、点和横线。- 变量面板能看到 collect 产出的字段。
提交前检查清单
- [ ] 新脚本中所有
extract_profile都已在extract.profiles注册。 - [ ] 新脚本中所有
decision_profile都已在decision.profiles注册。 - [ ]
branches[*].key与出边sourceHandle完全一致。 - [ ]
save_as使用稳定 key,且只包含字母、数字、下划线、点和横线。 - [ ] 新增 profile 已出现在
/api/v1/nlu/capabilities。 - [ ] 已补测试和文档。