跳到正文

流程脚本与 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 唯一,所有连线端点必须存在。
  • startsendtransfer 必须各有一个默认出口,collect 必须有一个 success 出口。
  • router.branches[*].key 必须唯一,并与出边 sourceHandle 一一对应;不允许隐式默认分支。
  • end 不允许出边;所有节点必须从 start 可达。流程允许形成循环,运行时以最多 100 步防止无限执行。
  • collect 字段 key、router/transfer save_as 必须合法且不能冲突;模板只能引用已声明变量。
  • collect.data.extract_profilerouter.data.decision_profile 必须非空、格式合法,并已在对应 NLU action 注册。
  • 数据库中的旧脚本若不满足上述契约,运行时接口返回 422,不会替换成空流程继续执行。

当前模板覆盖

当前流程脚本模板使用的 profile:

脚本节点profileaction状态
场景模板 - 客户资料采集collect-profilecustomer-profileextract已注册
场景模板 - 意向分流跟进route-intentsales-intentdecision已注册
场景模板 - 预约回访确认collect-appointmentappointment-confirmextract已注册
场景模板 - 售后满意度回访collect-feedbackservice-feedbackextract已注册
场景模板 - 售后满意度回访route-feedbackservice-satisfactiondecision已注册
场景模板 - 投诉安抚与转人工collect-complaintcomplaint-summaryextract已注册
场景模板 - 投诉安抚与转人工route-complaintcomplaint-routingdecision已注册

新增脚本流程

  1. 设计流程图时,先列出所有需要 NLU 的节点。
  2. 对每个 collect 节点确认字段列表、字段类型、必填项和 extract_profile
  3. 对每个 router 节点确认分支 key、分支描述、save_asdecision_profile
  4. apps/llm-nlu-server/src/nlu-registry.ts 注册对应 profile。
  5. 更新 apps/llm-nlu-server/src/handlers.test.ts,至少覆盖 profile 解析与能力发现。
  6. 更新 apps/llm-nlu-server/README.md 的内置 profile 表。
  7. 通过 /api/v1/nlu/capabilities 确认 profile 已出现在对应 action 下。
  8. 保存流程脚本后,进入 http://localhost:19920/#/flow-scripts 打开脚本,确认节点属性与变量目录正常。

profile 命名规范

  • 使用小写 kebab-case,例如 appointment-confirm
  • key 应表达业务语义,而不是模型名、页面名或临时任务名。
  • extractdecision 可以存在同名 profile,但它们属于不同 action,语义必须清晰。
  • 不要把实验性 key 写入生产脚本;需要试验时先用单独脚本和明确前缀。

collect 节点规范

collect 节点必须包含:

  • prompt:向客户提问的话术。
  • error_prompt:第一次抽取缺必填字段后的补问话术。
  • fields:允许抽取并写入 variables 的字段白名单。
  • extract_profile:必填的字段抽取策略 key,只能从 extract.profiles 选择。

字段类型:

type返回值
text字符串或 null
number数字或 null
booleantrue / 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.jointransfer-<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 中,decisionextract 分开注册:

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
  • [ ] 已补测试和文档。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。