📎 配套脚本附件正文未内嵌 · 共 7 个 · 点开可在线查看
📚 另需公共库(正文未重复内嵌,点名字直接看):_agent_cfg.py、_bootstrap.py、_config.py、_plugin_util.py
📂 查看全部 761 个脚本附件 →
⚠️ 脚本内含明文凭据(与本页正文一致),请勿在公网环境直接运行。
实战作业

作业 2 · 客服意图路由 Workflow

意图识别 → 知识库 / 工单 API / 转人工 / 兜底;含 draft 同步与三句验收。

作业 2:搭建客服意图路由工作流(进阶)

作业目标:用 Dify Workflow 搭一条客服意图路由:用户输入 → 意图识别 → 知识库 / 工单 API / 转人工 / 兜底。

验收标准:

1. 「怎么请假」→ 知识库检索 → 生成回答

2. 「帮我报修设备」→ 调用工单 API → 返回工单号

3. 「我要投诉」→ 转人工提示

环境:192.168.110.207(Dify 1.16.1 + 工单模拟 API :8778 + 行政知识库)+ 116.62.10.183(vLLM Qwen3.6-27B)。

HTML 成品:docs/html_cursor/作业2_客服意图路由工作流_cursor.html

运行环境:业务脚本均在 207 固定目录 /opt/workbuddy 执行,Python 为 /usr/bin/python3。 Windows 开发机仅保留 _probe_* 调试脚本;同步命令:python scripts_cursor/完整代码约定/00_部署_workbuddy到207.py。


目录

  1. 做完长什么样
  2. 第一部分 原理:Workflow 意图路由怎么跑
  3. 第二部分 前置条件
  4. 第三部分 详细操作步骤
  5. 第四部分 CPU 与 GPU 在本作业中的分工
  6. 第五部分 完整代码程序
  7. 第六部分 本次实战问题与解决方案
  8. 第七部分 验收清单

做完长什么样

项本环境实测值
Workflow 名称客服意图路由工作流
app_idf0e82c66-e324-4df7-a1fe-44d6ce6ec7cf
工单模拟 APIhttp://192.168.110.207:8778/ticket
知识库ee2495bf-c370-49d1-8268-10c51f650fa6(播城内部行政管理知识库)
意图模型qwen3.6-27b(116 vLLM)
验收问句怎么请假 / 帮我报修设备 / 我要投诉

控制台:http://192.168.110.207 → 客服意图路由工作流 → 运行 → 输入验收句。

脚本验收:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py → [PASS] 三句验收均走对应路径。


第一部分 原理:Workflow 意图路由怎么跑

1.1 与作业 1(Agent + 工具)的区别

作业 1 Agent作业 2 Workflow
应用类型mode=agent,创建口 POST http://127.0.0.1/console/api/agentmode=workflow,创建口 POST http://127.0.0.1/console/api/apps
路由方式模型自行决定调哪个 tool图里 显式分支(本作业:LLM + 代码 + IF/ELSE)
知识库本作业未用knowledge-retrieval 节点
外部 HTTPOpenAPI 自定义工具http-request 节点
验收 APIPOST http://127.0.0.1/console/api/agent/01a0189c-7d7e-742f-83d1-74c5c16878da/chat-messagesPOST http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run

1.2 本作业图结构(与题目一致)

text
用户输入(Start)
    │
    ▼
意图识别(LLM · Qwen3.6)
    │
    ▼
抽出意图标签(Code · 四类中文标签)
    │
    ▼
意图路由(IF/ELSE)
    ├─ 知识库问答 → 知识库检索 → 生成回答 → End
    ├─ 工单报修   → HTTP POST http://192.168.110.207:8778/ticket → 模板拼工单号 → End
    ├─ 人工客服   → 转人工固定文案 → End
    └─ 其他       → 兜底固定文案 → End

说明:Dify 自带 question-classifier(问题分类器) 节点在 UI 上最贴题,但本环境 Qwen3.6 带 reasoning 时常不输出 json,会报 could not find json block in the output.。因此交付图采用 LLM + Code + IF/ELSE,控制台展示名仍为「意图识别 / 意图路由」,逻辑与题目一致。详见 第六部分。

1.3 完整执行流程(逐步)

text
【0】POST http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run
      inputs.query = "怎么请假"
        │
        ▼
【1】Start 写入变量 query
        │
        ▼
【2】LLM 意图识别(116 vLLM)
      输出四类标签之一:知识库问答 / 工单报修 / 人工客服 / 其他
        │
        ▼
【3】Code 清洗  并规范化 intent 字符串
        │
        ▼
【4】IF/ELSE 按 intent 走四条边之一
        │
        ├─ 知识库路径
        │     Xinference bge-m3 向量检索 + bge-reranker(207 CPU)
        │     → LLM 读 context 生成回答
        │
        ├─ 工单路径
        │     docker-api / worker 经 SSRF 访问 207:8778
        │     → 返回 {"ticket_id":"WO-xxxx",...}
        │
        └─ 人工 / 其他
              Template 固定文案 → End.outputs.answer

第二部分 前置条件

#条件检查
1Dify 1.16 可登录http://192.168.110.207
2vLLM Qwen3.6-27B 可用专题一 / 五
3行政知识库已建且可检索DS_ID 见 _config.py
4Xinference bge-m3 + rerank 在跑专题二 / 十三
58778 端口未被占用8777 已被作业 1 占用
6Workflow 草稿需 POST graph 初始化GET .../workflows/draft 首次 404 正常

第三部分 详细操作步骤

3.0 一键顺序(脚本)

bash
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

换环境只改 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/_config.py 中 TICKET_API_*、HOMEWORK2_*、DS_ID。

3.0.1 全局图景(Workflow 四条分支)

阶段脚本目的
部署01 → 03工单 API + Workflow 空壳 + 同步 graph 草稿
验收04三句话分别走知识库 / 工单 / 转人工
text
用户输入
  → Workflow「开始」→ LLM 意图识别(四类)
  ├─ 产品咨询 → 知识库检索 → LLM 回答
  ├─ 报修工单 → HTTP 调 :8789 创建工单
  ├─ 转人工   → 固定回复 + 人工队列
  └─ 其它     → 兜底话术

各脚本四要素速查(graph/curl 细节见下方 3.1~3.4):

脚本干什么触发什么成功标志典型失败
01_部署工单模拟服务启动 :8789 工单 APIuvicorn + POST /tickets 探针[OK] + health8789 占用
02_创建工作流空壳POST /apps mode=workflow仅建空应用already exists / 201误用 agent 模式
03_同步工作流草稿POST 整图 graph+features14 节点写入 draft[OK] draft synced404 draft 不存在、hash 冲突
04_验收三句draft/run 三句 query意图路由到不同分支[PASS] 三句验收均走对应路径DS_ID 错、知识库空

下方保留完整 API curl;第六部分 有 Workflow 专项排错。

控制台 API 完整命令(登录一次,后续接口都能打)

Dify 1.16 控制台接口必须:登录拿 cookie + X-CSRF-Token。密码字段是 base64。脚本 dify_api.py 在 207 本机打 http://127.0.0.1/console/api。

bash
ssh root@192.168.110.207
rm -f /tmp/dify_cj.txt
python3 - <<'PY'
import json, base64
open("/tmp/dify_login.json", "w").write(json.dumps({
    "email": "38205036@qq.com",
    "password": base64.b64encode(b"Difyai123456").decode(),
    "language": "zh-Hans",
    "remember_me": True,
}))
PY
curl -s -c /tmp/dify_cj.txt -X POST 'http://127.0.0.1/console/api/login' \
  -H 'Content-Type: application/json' \
  -d @/tmp/dify_login.json
CSRF=$(python3 - <<'PY'
jar=open("/tmp/dify_cj.txt").read()
for line in jar.splitlines():
    if "csrf_token" in line:
        print(line.split("\t")[-1].strip())
PY
)
echo "CSRF=$CSRF"

列出 Workflow:

bash
curl -s -b /tmp/dify_cj.txt -H "X-CSRF-Token: $CSRF" \
  'http://127.0.0.1/console/api/apps?page=1&limit=50'

创建 Workflow 空壳:

bash
curl -s -b /tmp/dify_cj.txt -c /tmp/dify_cj.txt \
  -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
  -X POST 'http://127.0.0.1/console/api/apps' \
  -d '{
    "name": "客服意图路由工作流",
    "description": "作业 2:客服意图路由(知识库 / 工单报修 / 人工客服 / 兜底)",
    "mode": "workflow",
    "icon_type": "emoji",
    "icon": "🎧",
    "icon_background": "#E3F2FD"
  }'

本环境已存在时 app_id 为 f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf,不必重复创建。

读草稿 / 同步草稿: graph 很大,请用 /usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py。手工核对草稿是否存在:

bash
curl -s -b /tmp/dify_cj.txt -H "X-CSRF-Token: $CSRF" \
  'http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft'

新应用第一次可能 404 draft_workflow_not_exist,必须 POST 同一 URL 提交 graph + features(脚本会带上 hash 避免覆盖冲突)。

三句验收(草稿运行,本环境真实 URL):

bash
curl -s -b /tmp/dify_cj.txt -c /tmp/dify_cj.txt \
  -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
  --max-time 420 \
  -X POST 'http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run' \
  -d '{"inputs":{"query":"怎么请假"},"files":[]}'

curl -s -b /tmp/dify_cj.txt -c /tmp/dify_cj.txt \
  -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
  --max-time 420 \
  -X POST 'http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run' \
  -d '{"inputs":{"query":"帮我报修设备"},"files":[]}'

curl -s -b /tmp/dify_cj.txt -c /tmp/dify_cj.txt \
  -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
  --max-time 420 \
  -X POST 'http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run' \
  -d '{"inputs":{"query":"我要投诉"},"files":[]}'

工单模拟 API(不需要登录):

bash
curl -s 'http://192.168.110.207:8778/health'
curl -s -X POST 'http://192.168.110.207:8778/ticket' \
  -H 'Content-Type: application/json' \
  -d '{"query":"帮我报修设备","category":"repair"}'

3.1 步骤 1 · 部署工单模拟 API(207 CPU)

理论

Workflow 的 HTTP 请求 节点不会自动帮你注册 OpenAPI 工具(那是 Agent 的路子)。这里在宿主机起一个 FastAPI,模拟 POST http://192.168.110.207:8778/ticket 返回工单号;Dify 容器必须访问 192.168.110.207:8778(不要写 localhost)。

操作

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 1:把工单模拟服务部署到 207 并启动。"""
from __future__ import annotations

import sys
from pathlib import Path

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify

REMOTE_DIR = "/opt/ticket_api"
LOCAL_API = Path(__file__).resolve().parent / "作业2_ticket_api.py"
LOCAL_SH = Path(__file__).resolve().parents[1] / "scripts_bash_cursor" / "作业2_启动_ticket.sh"


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        d.sh("mkdir -p %s" % REMOTE_DIR)
        d.sftp.put(str(LOCAL_API), REMOTE_DIR + "/作业2_ticket_api.py")
        d.sftp.put(str(LOCAL_SH), REMOTE_DIR + "/start.sh")
        d.sh("chmod +x %s/start.sh" % REMOTE_DIR)
        d.sh(
            "if [[ -f /tmp/ticket_api.pid ]]; then "
            "kill $(cat /tmp/ticket_api.pid) 2>/dev/null || true; "
            "rm -f /tmp/ticket_api.pid; fi"
        )
        out, err = d.sh("TICKET_API_PORT=%d bash %s/start.sh" % (C.TICKET_API_PORT, REMOTE_DIR))
        print(out or err)
        r = d.sh(
            "curl -s -X POST 'http://127.0.0.1:%d/ticket' "
            "-H 'Content-Type: application/json' "
            "-d '{\"query\":\"帮我报修设备\",\"category\":\"repair\"}'"
            % C.TICKET_API_PORT
        )
        print("probe:", r[0].strip())
        return 0 if "WO-" in (r[0] or "") else 1
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

完整脚本:scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh

bash
#!/usr/bin/env bash
# 作业 2 · 在 207 上启动模拟「工单报修」FastAPI 服务
set -euo pipefail

DIR="/opt/ticket_api"
PORT="${TICKET_API_PORT:-8778}"
PIDFILE="/tmp/ticket_api.pid"
LOG="/tmp/ticket_api.log"

mkdir -p "$DIR"
pip3 install fastapi uvicorn -q

if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
  echo "ticket_api already running pid=$(cat "$PIDFILE")"
  exit 0
fi

cd "$DIR"
nohup python3 -m uvicorn 作业2_ticket_api:app \
  --host 0.0.0.0 --port "$PORT" >>"$LOG" 2>&1 &
echo $! >"$PIDFILE"
sleep 1
curl -sf "http://127.0.0.1:${PORT}/health" && echo
echo "started on :${PORT}, log=$LOG"

探针

bash
curl -s -X POST 'http://192.168.110.207:8778/ticket' \
  -H 'Content-Type: application/json' \
  -d '{"query":"帮我报修设备","category":"repair"}'
curl -s 'http://192.168.110.207:8778/health'

期望:JSON 含 "ticket_id":"WO-1001" 这类字段。

FastAPI 源码

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 模拟「工单报修」HTTP API(FastAPI)。

启动(207 上):
  pip3 install fastapi uvicorn -q
  uvicorn 作业2_ticket_api:app --host 0.0.0.0 --port 8778

或:bash /opt/workbuddy/scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh
"""
from __future__ import annotations

import itertools
from typing import Any

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(
    title="Ticket API",
    version="1.0.0",
    description="Dify Workflow 作业 2:模拟创建报修工单,返回工单号",
)

_seq = itertools.count(1001)


class TicketIn(BaseModel):
    query: str = Field(default="", description="用户报修描述")
    category: str = Field(default="repair", description="工单类别")


@app.post("/ticket", operation_id="createTicket", summary="创建报修工单")
def create_ticket(body: TicketIn) -> dict[str, Any]:
    n = next(_seq)
    ticket_id = "WO-%d" % n
    return {
        "ticket_id": ticket_id,
        "status": "created",
        "category": body.category,
        "issue": body.query or "未填写故障描述",
        "message": "报修工单已创建,工单号 %s" % ticket_id,
    }


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run("作业2_ticket_api:app", host="0.0.0.0", port=8778, reload=False)

3.2 步骤 2 · 创建 Workflow 空壳

与 Agent 不同,Workflow 仍用 POST http://127.0.0.1/console/api/apps,body 里 "mode": "workflow"。完整 curl 见上文「创建 Workflow 空壳」。

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 2:创建 Workflow 空壳(mode=workflow)。"""
from __future__ import annotations

import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp


def find_app(d: Dify) -> dict | None:
    r = d.get("/apps?page=1&limit=50")
    for item in (r.get("json") or {}).get("data") or []:
        if item.get("name") == C.HOMEWORK2_APP_NAME and item.get("mode") == "workflow":
            return item
    aid = getattr(C, "HOMEWORK2_APP_ID", "") or ""
    if aid:
        for item in (r.get("json") or {}).get("data") or []:
            if item.get("id") == aid:
                return item
    return None


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if found:
            print("already exists app_id=", found.get("id"), "mode=", found.get("mode"))
            print("下一步:运行 作业2_03_同步工作流草稿.py")
            return 0
        body = {
            "name": C.HOMEWORK2_APP_NAME,
            "description": "作业 2:客服意图路由(知识库 / 工单报修 / 人工客服 / 兜底)",
            "mode": "workflow",
            "icon_type": "emoji",
            "icon": "🎧",
            "icon_background": "#E3F2FD",
        }
        r = d.post("/apps", body)
        pp("create workflow app", r)
        j = r.get("json") or {}
        print("app_id", j.get("id"))
        if r.get("code") not in ("200", "201"):
            return 1
        print("下一步:运行 作业2_03_同步工作流草稿.py")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

本环境已存在时脚本会打印 app_id=f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf 并跳过创建。


3.3 步骤 3 · 同步工作流 graph 到草稿

理论

graph 定义(完整)

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 客服意图路由工作流 graph 定义(给草稿同步用)。

结构:用户输入 → LLM 意图识别 → 代码抽出类别 → IF/ELSE
  ├ 知识库问答 → 知识库检索 → 生成回答 → 结束
  ├ 工单报修 → HTTP 工单 API → 返回工单号 → 结束
  ├ 人工客服 → 转人工提示 → 结束
  └ 其他 → 兜底回复 → 结束

不用 question-classifier:Qwen3.6 带 reasoning 时经常不吐 ```json```,
该节点会报 could not find json block。
"""
from __future__ import annotations

from typing import Any

import _config as C

CLS_KB = "cls_kb"
CLS_TICKET = "cls_ticket"
CLS_HUMAN = "cls_human"
CLS_OTHER = "cls_other"

N_START = "start"
N_LLM_INTENT = "llm_intent"
N_PARSE = "parse_intent"
N_IF = "route"
N_KR = "kr"
N_LLM_KB = "llm_kb"
N_HTTP = "http_ticket"
N_TPL_TICKET = "tpl_ticket"
N_TPL_HUMAN = "tpl_human"
N_TPL_OTHER = "tpl_other"
N_END_KB = "end_kb"
N_END_TICKET = "end_ticket"
N_END_HUMAN = "end_human"
N_END_OTHER = "end_other"

PARSE_CODE = r'''
import re

LABELS = ("人工客服", "工单报修", "知识库问答", "其他")


def main(text: str) -> dict:
    raw = text or ""
    cleaned = re.sub(r"<think[^>]*>[\s\S]*?</think>", "", raw, flags=re.I)
    # 优先看清洗后的正文,避免思考链里同时出现多个类别名
    for blob in (cleaned, raw):
        for lab in LABELS:
            if lab in blob:
                return {"intent": lab}
    low = (cleaned or raw).lower()
    if any(k in low for k in ("投诉", "complain", "human", "转人工")):
        return {"intent": "人工客服"}
    if any(k in low for k in ("报修", "repair", "ticket", "工单")):
        return {"intent": "工单报修"}
    if any(k in low for k in ("请假", "制度", "knowledge", "知识库")):
        return {"intent": "知识库问答"}
    return {"intent": "其他"}
'''.lstrip()

INTENT_SYS = """你是客服意图分类器。根据用户原话,只输出下面四个标签之一,不要解释、不要思考过程、不要 Markdown。

标签:
- 人工客服:投诉、举报、找领导、要人工、转人工。例句:我要投诉
- 工单报修:报修、设备故障、开维修单。例句:帮我报修设备
- 知识库问答:制度、流程、请假、加班、打卡、福利。例句:怎么请假
- 其他:闲聊或无法归类

只输出这四个中文词之一。"""


def _model(max_tokens: int, temperature: float = 0.2) -> dict[str, Any]:
    return {
        "provider": C.OPENAI_PROVIDER,
        "name": C.QWEN_MODEL,
        "mode": "chat",
        "completion_params": {
            "temperature": temperature,
            "max_tokens": max_tokens,
            "enable_thinking": False,
        },
    }


def _node(nid: str, ntype: str, title: str, data: dict[str, Any], x: float, y: float, h: int = 110) -> dict[str, Any]:
    payload = {
        "type": ntype,
        "title": title,
        "desc": "",
        "selected": False,
        **data,
    }
    return {
        "id": nid,
        "type": "custom",
        "position": {"x": x, "y": y},
        "positionAbsolute": {"x": x, "y": y},
        "sourcePosition": "right",
        "targetPosition": "left",
        "width": 244,
        "height": h,
        "data": payload,
    }


def _edge(src: str, dst: str, src_type: str, dst_type: str, handle: str = "source") -> dict[str, Any]:
    return {
        "id": "%s-%s-%s" % (src, handle, dst),
        "source": src,
        "sourceHandle": handle,
        "target": dst,
        "targetHandle": "target",
        "type": "custom",
        "zIndex": 0,
        "data": {
            "sourceType": src_type,
            "targetType": dst_type,
            "isInIteration": False,
        },
    }


FEATURES: dict[str, Any] = {
    "file_upload": {
        "image": {
            "enabled": False,
            "number_limits": 3,
            "transfer_methods": ["local_file", "remote_url"],
        }
    },
    "opening_statement": "",
    "retriever_resource": {"enabled": True},
    "sensitive_word_avoidance": {"enabled": False},
    "speech_to_text": {"enabled": False},
    "suggested_questions": [],
    "suggested_questions_after_answer": {"enabled": False},
    "text_to_speech": {"enabled": False, "language": "", "voice": ""},
}


def build_graph() -> dict[str, Any]:
    ticket_url = C.TICKET_API_BASE.rstrip("/") + "/ticket"
    body_json = '{"query": "{{#%s.query#}}", "category": "repair"}' % N_START
    nodes = [
        _node(
            N_START,
            "start",
            "用户输入",
            {
                "variables": [
                    {
                        "variable": "query",
                        "label": "用户问题",
                        "type": "paragraph",
                        "required": True,
                        "max_length": 2000,
                        "options": [],
                    }
                ]
            },
            40,
            420,
            90,
        ),
        _node(
            N_LLM_INTENT,
            "llm",
            "意图识别",
            {
                "model": _model(1024, 0.0),
                "prompt_template": [
                    {"role": "system", "text": INTENT_SYS, "edition_type": "basic"},
                    {
                        "role": "user",
                        "text": "用户原话:{{#%s.query#}}" % N_START,
                        "edition_type": "basic",
                    },
                ],
                "context": {"enabled": False, "variable_selector": []},
                "vision": {"enabled": False},
            },
            330,
            400,
            140,
        ),
        _node(
            N_PARSE,
            "code",
            "抽出意图标签",
            {
                "variables": [
                    {"variable": "text", "value_selector": [N_LLM_INTENT, "text"]}
                ],
                "code_language": "python3",
                "code": PARSE_CODE,
                "outputs": {"intent": {"type": "string", "children": None}},
            },
            620,
            400,
            100,
        ),
        _node(
            N_IF,
            "if-else",
            "意图路由",
            {
                "cases": [
                    {
                        "case_id": CLS_KB,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_kb",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "知识库问答",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_TICKET,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_ticket",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "工单报修",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_HUMAN,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_human",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "人工客服",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_OTHER,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_other",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "其他",
                            }
                        ],
                    },
                ]
            },
            900,
            380,
            220,
        ),
        _node(
            N_KR,
            "knowledge-retrieval",
            "知识库检索",
            {
                "query_variable_selector": [N_START, "query"],
                "dataset_ids": [C.DS_ID],
                "retrieval_mode": "multiple",
                "multiple_retrieval_config": {
                    "top_k": 5,
                    "score_threshold": None,
                    "reranking_enable": True,
                    "reranking_mode": "reranking_model",
                    "reranking_model": {
                        "provider": C.XINFERENCE_PROVIDER,
                        "model": C.RERANK_MODEL,
                    },
                },
            },
            1180,
            40,
            100,
        ),
        _node(
            N_LLM_KB,
            "llm",
            "生成回答",
            {
                "model": _model(2048, 0.3),
                "prompt_template": [
                    {
                        "role": "system",
                        "text": (
                            "你是播城内部行政客服。只根据知识库片段回答,不要编造。"
                            "若片段不足,明确说不知道并建议转人工。\n"
                            "使用 <context></context> 中的资料:\n"
                            "<context>\n{{#context#}}\n</context>"
                        ),
                        "edition_type": "basic",
                    },
                    {
                        "role": "user",
                        "text": "用户问题:{{#%s.query#}}" % N_START,
                        "edition_type": "basic",
                    },
                ],
                "context": {
                    "enabled": True,
                    "variable_selector": [N_KR, "result"],
                },
                "vision": {"enabled": False},
            },
            1480,
            40,
            150,
        ),
        _node(
            N_END_KB,
            "end",
            "结束·知识库",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_LLM_KB, "text"]}
                ]
            },
            1780,
            40,
            90,
        ),
        _node(
            N_HTTP,
            "http-request",
            "调用工单API",
            {
                "method": "post",
                "url": ticket_url,
                "authorization": {"type": "no-auth", "config": None},
                "headers": "Content-Type:application/json",
                "params": "",
                "body": {
                    "type": "json",
                    "data": [{"type": "text", "key": "", "value": body_json}],
                },
                "timeout": {
                    "connect": 10,
                    "read": 30,
                    "write": 30,
                    "max_connect_timeout": 10,
                    "max_read_timeout": 60,
                    "max_write_timeout": 60,
                },
                "ssl_verify": False,
            },
            1180,
            280,
            150,
        ),
        _node(
            N_TPL_TICKET,
            "template-transform",
            "返回工单号",
            {
                "template": "已为您创建报修工单。\n{{ body }}\n请保存工单号,维修人员会联系您。",
                "variables": [
                    {"variable": "body", "value_selector": [N_HTTP, "body"]}
                ],
            },
            1480,
            280,
            90,
        ),
        _node(
            N_END_TICKET,
            "end",
            "结束·工单",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_TICKET, "output"]}
                ]
            },
            1780,
            280,
            90,
        ),
        _node(
            N_TPL_HUMAN,
            "template-transform",
            "转人工提示",
            {
                "template": (
                    "正在为您转接人工客服。投诉类诉求需要人工跟进,"
                    "请保持在线,工作人员会尽快接入。"
                ),
                "variables": [],
            },
            1180,
            500,
            90,
        ),
        _node(
            N_END_HUMAN,
            "end",
            "结束·人工",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_HUMAN, "output"]}
                ]
            },
            1480,
            500,
            90,
        ),
        _node(
            N_TPL_OTHER,
            "template-transform",
            "兜底回复",
            {
                "template": (
                    "我暂时无法判断您的具体诉求。您可以:\n"
                    "1)问制度流程(例如怎么请假);\n"
                    "2)报修设备;\n"
                    "3)明确说要投诉或转人工。"
                ),
                "variables": [],
            },
            1180,
            680,
            90,
        ),
        _node(
            N_END_OTHER,
            "end",
            "结束·其他",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_OTHER, "output"]}
                ]
            },
            1480,
            680,
            90,
        ),
    ]
    edges = [
        _edge(N_START, N_LLM_INTENT, "start", "llm"),
        _edge(N_LLM_INTENT, N_PARSE, "llm", "code"),
        _edge(N_PARSE, N_IF, "code", "if-else"),
        _edge(N_IF, N_KR, "if-else", "knowledge-retrieval", CLS_KB),
        _edge(N_KR, N_LLM_KB, "knowledge-retrieval", "llm"),
        _edge(N_LLM_KB, N_END_KB, "llm", "end"),
        _edge(N_IF, N_HTTP, "if-else", "http-request", CLS_TICKET),
        _edge(N_HTTP, N_TPL_TICKET, "http-request", "template-transform"),
        _edge(N_TPL_TICKET, N_END_TICKET, "template-transform", "end"),
        _edge(N_IF, N_TPL_HUMAN, "if-else", "template-transform", CLS_HUMAN),
        _edge(N_TPL_HUMAN, N_END_HUMAN, "template-transform", "end"),
        _edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", CLS_OTHER),
        _edge(N_TPL_OTHER, N_END_OTHER, "template-transform", "end"),
        _edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", "false"),
    ]
    return {
        "nodes": nodes,
        "edges": edges,
        "viewport": {"x": 0, "y": 0, "zoom": 0.65},
    }

同步脚本

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 3:把意图路由 graph 同步到 Workflow 草稿。"""
from __future__ import annotations

import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp
from 作业2_graph import FEATURES, build_graph
from 作业2_02_创建工作流空壳 import find_app


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if not found:
            print("找不到应用,请先跑 作业2_02_创建工作流空壳.py")
            return 1
        app_id = found["id"]
        print("app_id", app_id)

        hash_val = None
        r0 = d.get("/apps/%s/workflows/draft" % app_id)
        if r0.get("code") == "200":
            hash_val = (r0.get("json") or {}).get("hash")
            print("existing draft hash", hash_val)

        body = {
            "graph": build_graph(),
            "features": FEATURES,
            "environment_variables": [],
            "conversation_variables": [],
        }
        if hash_val:
            body["hash"] = hash_val

        r = d.post("/apps/%s/workflows/draft" % app_id, body)
        pp("sync draft", r, limit=2000)
        if r.get("code") not in ("200", "201"):
            return 1

        # 再读一遍确认节点数量
        r2 = d.get("/apps/%s/workflows/draft" % app_id)
        g = ((r2.get("json") or {}).get("graph")) or {}
        print("nodes", len(g.get("nodes") or []), "edges", len(g.get("edges") or []))
        print("hash", (r.get("json") or {}).get("hash") or (r2.get("json") or {}).get("hash"))
        print("下一步:运行 作业2_04_验收三句.py")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

成功时应看到:nodes 14 edges 14 与新的 hash。


3.4 步骤 4 · 三句验收

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 4:三句验收(知识库 / 工单 / 人工)。"""
from __future__ import annotations

import json
import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp
from 作业2_02_创建工作流空壳 import find_app


def parse_sse(text: str) -> dict:
    """从 Dify workflow 流式响应里抽出分类名、结束输出、错误。"""
    class_name = ""
    answer = ""
    errors: list[str] = []
    node_titles: list[str] = []
    for raw in text.splitlines():
        line = raw.strip()
        if line.startswith("data:"):
            line = line[5:].strip()
        if not line.startswith("{"):
            continue
        try:
            ev = json.loads(line)
        except json.JSONDecodeError:
            continue
        event = ev.get("event") or ""
        data = ev.get("data") or ev
        title = (data.get("title") or data.get("node_type") or "") if isinstance(data, dict) else ""
        if event in ("node_started", "node_finished") and title:
            node_titles.append("%s:%s" % (event, title))
        if event == "node_finished":
            outputs = data.get("outputs") or {}
            if outputs.get("class_name"):
                class_name = str(outputs.get("class_name"))
            if outputs.get("class_label") and not class_name:
                class_name = str(outputs.get("class_label"))
            if outputs.get("intent"):
                class_name = str(outputs.get("intent"))
            err = data.get("error")
            if err:
                errors.append("%s: %s" % (title or data.get("node_id"), err))
        if event in ("workflow_finished", "workflow_succeeded"):
            outs = (data.get("outputs") or {}) if isinstance(data, dict) else {}
            if outs.get("answer"):
                answer = str(outs.get("answer"))
            elif outs:
                answer = json.dumps(outs, ensure_ascii=False)
        if event == "error":
            errors.append(str(data))
    if not answer:
        # 兜底:全文里找 WO- / 转人工 / 请假
        answer = text[-4000:]
    return {
        "class_name": class_name,
        "answer": answer,
        "errors": errors,
        "node_titles": node_titles[-30:],
    }


def run_one(d: Dify, app_id: str, query: str) -> dict:
    r = d.post(
        "/apps/%s/workflows/draft/run" % app_id,
        body={"inputs": {"query": query}, "files": []},
        extra="-m 420 --max-time 420",
        t=450,
    )
    parsed = parse_sse(r.get("text") or "")
    parsed["http"] = r.get("code")
    parsed["raw_head"] = (r.get("text") or "")[:800]
    return parsed


def main() -> int:
    cases = [
        (
            C.HOMEWORK2_PROBE_KB,
            "知识库问答",
            ("请假", "知识库", "制度", "流程", "hr", "行政"),
            ("知识库检索", "生成回答"),
        ),
        (
            C.HOMEWORK2_PROBE_TICKET,
            "工单报修",
            ("WO-", "工单", "报修"),
            ("调用工单API", "返回工单号"),
        ),
        (
            C.HOMEWORK2_PROBE_HUMAN,
            "人工客服",
            ("转接", "人工", "投诉"),
            ("转人工提示",),
        ),
    ]
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if not found:
            print("找不到应用")
            return 1
        app_id = found["id"]
        print("app_id", app_id)
        failed = 0
        for query, expect_cls, markers, path_nodes in cases:
            print("\n======== 问句:", query, "期望分类:", expect_cls)
            parsed = run_one(d, app_id, query)
            print("http", parsed["http"])
            print("class_name", parsed["class_name"])
            print("errors", parsed["errors"][:3])
            print("nodes", parsed["node_titles"])
            ans = parsed["answer"] or ""
            print("answer_head", ans[:500].replace("\n", " / "))
            hits = [m for m in markers if m.lower() in ans.lower() or m in (parsed["class_name"] or "")]
            cls_ok = expect_cls in (parsed["class_name"] or "") or expect_cls in ans
            path_ok = any(n in str(parsed["node_titles"]) for n in path_nodes)
            ok = parsed["http"] in ("200",) and (cls_ok or path_ok) and (len(hits) >= 1 or cls_ok)
            if not parsed["errors"] and ok:
                print("[PASS]")
            else:
                print("[FAIL] hits=", hits, "cls_ok=", cls_ok, "path_ok=", path_ok)
                failed += 1
                print("raw_head", parsed["raw_head"][:800])
        if failed:
            print("\n[FAIL] %d / %d 未通过" % (failed, len(cases)))
            return 1
        print("\n[PASS] 三句验收均走对应路径。")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())
问句期望 intent期望经过的节点
怎么请假知识库问答知识库检索 → 生成回答
帮我报修设备工单报修调用工单API → 返回工单号
我要投诉人工客服转人工提示

第四部分 CPU 与 GPU 在本作业中的分工

组件机器本作业作用
Qwen3.6-27B116 GPU · vLLM意图识别 LLM + 知识库生成 LLM(每问 1~2 次)
bge-m3207 CPU · Xinference知识库路径向量检索
bge-reranker-v2-m3207 CPU · Xinference知识库 Top-K 重排(graph 里已开 rerank)
FastAPI 工单207 CPU仅工单分支触发一次 HTTP
Weaviate207存向量
Dify worker207跑 workflow 节点

4.1 性能提示

4.2 未来有 GPU 时

项现状可选演进
Embedding/Rerank207 CPU迁 116 GPU Xinference(专题十三路径 B)
意图分类LLM 四分类可换小模型或微调分类器降延迟
工单 API模拟 FastAPI对接真实 ITSM / 飞书审批

第五部分 完整代码程序

文件用途
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py工单 FastAPI
scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh207 启动脚本
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.pyWorkflow graph + features
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py部署 + 探针
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.pyPOST http://127.0.0.1/console/api/apps(mode=workflow)
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.pyPOST http://127.0.0.1/console/api/apps/f0e82c66-.../workflows/draft
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.pyPOST .../workflows/draft/run 三句 query

环境常量(HOMEWORK2_*、TICKET_API_*)在 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/_config.py。


第六部分 本次实战问题与解决方案

完整配置(Workflow graph / HTTP 工单 / 验收脚本)

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 客服意图路由工作流 graph 定义(给草稿同步用)。

结构:用户输入 → LLM 意图识别 → 代码抽出类别 → IF/ELSE
  ├ 知识库问答 → 知识库检索 → 生成回答 → 结束
  ├ 工单报修 → HTTP 工单 API → 返回工单号 → 结束
  ├ 人工客服 → 转人工提示 → 结束
  └ 其他 → 兜底回复 → 结束

不用 question-classifier:Qwen3.6 带 reasoning 时经常不吐 ```json```,
该节点会报 could not find json block。
"""
from __future__ import annotations

from typing import Any

import _config as C

CLS_KB = "cls_kb"
CLS_TICKET = "cls_ticket"
CLS_HUMAN = "cls_human"
CLS_OTHER = "cls_other"

N_START = "start"
N_LLM_INTENT = "llm_intent"
N_PARSE = "parse_intent"
N_IF = "route"
N_KR = "kr"
N_LLM_KB = "llm_kb"
N_HTTP = "http_ticket"
N_TPL_TICKET = "tpl_ticket"
N_TPL_HUMAN = "tpl_human"
N_TPL_OTHER = "tpl_other"
N_END_KB = "end_kb"
N_END_TICKET = "end_ticket"
N_END_HUMAN = "end_human"
N_END_OTHER = "end_other"

PARSE_CODE = r'''
import re

LABELS = ("人工客服", "工单报修", "知识库问答", "其他")


def main(text: str) -> dict:
    raw = text or ""
    cleaned = re.sub(r"<think[^>]*>[\s\S]*?</think>", "", raw, flags=re.I)
    # 优先看清洗后的正文,避免思考链里同时出现多个类别名
    for blob in (cleaned, raw):
        for lab in LABELS:
            if lab in blob:
                return {"intent": lab}
    low = (cleaned or raw).lower()
    if any(k in low for k in ("投诉", "complain", "human", "转人工")):
        return {"intent": "人工客服"}
    if any(k in low for k in ("报修", "repair", "ticket", "工单")):
        return {"intent": "工单报修"}
    if any(k in low for k in ("请假", "制度", "knowledge", "知识库")):
        return {"intent": "知识库问答"}
    return {"intent": "其他"}
'''.lstrip()

INTENT_SYS = """你是客服意图分类器。根据用户原话,只输出下面四个标签之一,不要解释、不要思考过程、不要 Markdown。

标签:
- 人工客服:投诉、举报、找领导、要人工、转人工。例句:我要投诉
- 工单报修:报修、设备故障、开维修单。例句:帮我报修设备
- 知识库问答:制度、流程、请假、加班、打卡、福利。例句:怎么请假
- 其他:闲聊或无法归类

只输出这四个中文词之一。"""


def _model(max_tokens: int, temperature: float = 0.2) -> dict[str, Any]:
    return {
        "provider": C.OPENAI_PROVIDER,
        "name": C.QWEN_MODEL,
        "mode": "chat",
        "completion_params": {
            "temperature": temperature,
            "max_tokens": max_tokens,
            "enable_thinking": False,
        },
    }


def _node(nid: str, ntype: str, title: str, data: dict[str, Any], x: float, y: float, h: int = 110) -> dict[str, Any]:
    payload = {
        "type": ntype,
        "title": title,
        "desc": "",
        "selected": False,
        **data,
    }
    return {
        "id": nid,
        "type": "custom",
        "position": {"x": x, "y": y},
        "positionAbsolute": {"x": x, "y": y},
        "sourcePosition": "right",
        "targetPosition": "left",
        "width": 244,
        "height": h,
        "data": payload,
    }


def _edge(src: str, dst: str, src_type: str, dst_type: str, handle: str = "source") -> dict[str, Any]:
    return {
        "id": "%s-%s-%s" % (src, handle, dst),
        "source": src,
        "sourceHandle": handle,
        "target": dst,
        "targetHandle": "target",
        "type": "custom",
        "zIndex": 0,
        "data": {
            "sourceType": src_type,
            "targetType": dst_type,
            "isInIteration": False,
        },
    }


FEATURES: dict[str, Any] = {
    "file_upload": {
        "image": {
            "enabled": False,
            "number_limits": 3,
            "transfer_methods": ["local_file", "remote_url"],
        }
    },
    "opening_statement": "",
    "retriever_resource": {"enabled": True},
    "sensitive_word_avoidance": {"enabled": False},
    "speech_to_text": {"enabled": False},
    "suggested_questions": [],
    "suggested_questions_after_answer": {"enabled": False},
    "text_to_speech": {"enabled": False, "language": "", "voice": ""},
}


def build_graph() -> dict[str, Any]:
    ticket_url = C.TICKET_API_BASE.rstrip("/") + "/ticket"
    body_json = '{"query": "{{#%s.query#}}", "category": "repair"}' % N_START
    nodes = [
        _node(
            N_START,
            "start",
            "用户输入",
            {
                "variables": [
                    {
                        "variable": "query",
                        "label": "用户问题",
                        "type": "paragraph",
                        "required": True,
                        "max_length": 2000,
                        "options": [],
                    }
                ]
            },
            40,
            420,
            90,
        ),
        _node(
            N_LLM_INTENT,
            "llm",
            "意图识别",
            {
                "model": _model(1024, 0.0),
                "prompt_template": [
                    {"role": "system", "text": INTENT_SYS, "edition_type": "basic"},
                    {
                        "role": "user",
                        "text": "用户原话:{{#%s.query#}}" % N_START,
                        "edition_type": "basic",
                    },
                ],
                "context": {"enabled": False, "variable_selector": []},
                "vision": {"enabled": False},
            },
            330,
            400,
            140,
        ),
        _node(
            N_PARSE,
            "code",
            "抽出意图标签",
            {
                "variables": [
                    {"variable": "text", "value_selector": [N_LLM_INTENT, "text"]}
                ],
                "code_language": "python3",
                "code": PARSE_CODE,
                "outputs": {"intent": {"type": "string", "children": None}},
            },
            620,
            400,
            100,
        ),
        _node(
            N_IF,
            "if-else",
            "意图路由",
            {
                "cases": [
                    {
                        "case_id": CLS_KB,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_kb",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "知识库问答",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_TICKET,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_ticket",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "工单报修",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_HUMAN,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_human",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "人工客服",
                            }
                        ],
                    },
                    {
                        "case_id": CLS_OTHER,
                        "logical_operator": "and",
                        "conditions": [
                            {
                                "id": "c_other",
                                "variable_selector": [N_PARSE, "intent"],
                                "comparison_operator": "is",
                                "value": "其他",
                            }
                        ],
                    },
                ]
            },
            900,
            380,
            220,
        ),
        _node(
            N_KR,
            "knowledge-retrieval",
            "知识库检索",
            {
                "query_variable_selector": [N_START, "query"],
                "dataset_ids": [C.DS_ID],
                "retrieval_mode": "multiple",
                "multiple_retrieval_config": {
                    "top_k": 5,
                    "score_threshold": None,
                    "reranking_enable": True,
                    "reranking_mode": "reranking_model",
                    "reranking_model": {
                        "provider": C.XINFERENCE_PROVIDER,
                        "model": C.RERANK_MODEL,
                    },
                },
            },
            1180,
            40,
            100,
        ),
        _node(
            N_LLM_KB,
            "llm",
            "生成回答",
            {
                "model": _model(2048, 0.3),
                "prompt_template": [
                    {
                        "role": "system",
                        "text": (
                            "你是播城内部行政客服。只根据知识库片段回答,不要编造。"
                            "若片段不足,明确说不知道并建议转人工。\n"
                            "使用 <context></context> 中的资料:\n"
                            "<context>\n{{#context#}}\n</context>"
                        ),
                        "edition_type": "basic",
                    },
                    {
                        "role": "user",
                        "text": "用户问题:{{#%s.query#}}" % N_START,
                        "edition_type": "basic",
                    },
                ],
                "context": {
                    "enabled": True,
                    "variable_selector": [N_KR, "result"],
                },
                "vision": {"enabled": False},
            },
            1480,
            40,
            150,
        ),
        _node(
            N_END_KB,
            "end",
            "结束·知识库",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_LLM_KB, "text"]}
                ]
            },
            1780,
            40,
            90,
        ),
        _node(
            N_HTTP,
            "http-request",
            "调用工单API",
            {
                "method": "post",
                "url": ticket_url,
                "authorization": {"type": "no-auth", "config": None},
                "headers": "Content-Type:application/json",
                "params": "",
                "body": {
                    "type": "json",
                    "data": [{"type": "text", "key": "", "value": body_json}],
                },
                "timeout": {
                    "connect": 10,
                    "read": 30,
                    "write": 30,
                    "max_connect_timeout": 10,
                    "max_read_timeout": 60,
                    "max_write_timeout": 60,
                },
                "ssl_verify": False,
            },
            1180,
            280,
            150,
        ),
        _node(
            N_TPL_TICKET,
            "template-transform",
            "返回工单号",
            {
                "template": "已为您创建报修工单。\n{{ body }}\n请保存工单号,维修人员会联系您。",
                "variables": [
                    {"variable": "body", "value_selector": [N_HTTP, "body"]}
                ],
            },
            1480,
            280,
            90,
        ),
        _node(
            N_END_TICKET,
            "end",
            "结束·工单",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_TICKET, "output"]}
                ]
            },
            1780,
            280,
            90,
        ),
        _node(
            N_TPL_HUMAN,
            "template-transform",
            "转人工提示",
            {
                "template": (
                    "正在为您转接人工客服。投诉类诉求需要人工跟进,"
                    "请保持在线,工作人员会尽快接入。"
                ),
                "variables": [],
            },
            1180,
            500,
            90,
        ),
        _node(
            N_END_HUMAN,
            "end",
            "结束·人工",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_HUMAN, "output"]}
                ]
            },
            1480,
            500,
            90,
        ),
        _node(
            N_TPL_OTHER,
            "template-transform",
            "兜底回复",
            {
                "template": (
                    "我暂时无法判断您的具体诉求。您可以:\n"
                    "1)问制度流程(例如怎么请假);\n"
                    "2)报修设备;\n"
                    "3)明确说要投诉或转人工。"
                ),
                "variables": [],
            },
            1180,
            680,
            90,
        ),
        _node(
            N_END_OTHER,
            "end",
            "结束·其他",
            {
                "outputs": [
                    {"variable": "answer", "value_selector": [N_TPL_OTHER, "output"]}
                ]
            },
            1480,
            680,
            90,
        ),
    ]
    edges = [
        _edge(N_START, N_LLM_INTENT, "start", "llm"),
        _edge(N_LLM_INTENT, N_PARSE, "llm", "code"),
        _edge(N_PARSE, N_IF, "code", "if-else"),
        _edge(N_IF, N_KR, "if-else", "knowledge-retrieval", CLS_KB),
        _edge(N_KR, N_LLM_KB, "knowledge-retrieval", "llm"),
        _edge(N_LLM_KB, N_END_KB, "llm", "end"),
        _edge(N_IF, N_HTTP, "if-else", "http-request", CLS_TICKET),
        _edge(N_HTTP, N_TPL_TICKET, "http-request", "template-transform"),
        _edge(N_TPL_TICKET, N_END_TICKET, "template-transform", "end"),
        _edge(N_IF, N_TPL_HUMAN, "if-else", "template-transform", CLS_HUMAN),
        _edge(N_TPL_HUMAN, N_END_HUMAN, "template-transform", "end"),
        _edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", CLS_OTHER),
        _edge(N_TPL_OTHER, N_END_OTHER, "template-transform", "end"),
        _edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", "false"),
    ]
    return {
        "nodes": nodes,
        "edges": edges,
        "viewport": {"x": 0, "y": 0, "zoom": 0.65},
    }

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_ticket_api.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 模拟「工单报修」HTTP API(FastAPI)。

启动(207 上):
  pip3 install fastapi uvicorn -q
  uvicorn 作业2_ticket_api:app --host 0.0.0.0 --port 8778

或:bash /opt/workbuddy/scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh
"""
from __future__ import annotations

import itertools
from typing import Any

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(
    title="Ticket API",
    version="1.0.0",
    description="Dify Workflow 作业 2:模拟创建报修工单,返回工单号",
)

_seq = itertools.count(1001)


class TicketIn(BaseModel):
    query: str = Field(default="", description="用户报修描述")
    category: str = Field(default="repair", description="工单类别")


@app.post("/ticket", operation_id="createTicket", summary="创建报修工单")
def create_ticket(body: TicketIn) -> dict[str, Any]:
    n = next(_seq)
    ticket_id = "WO-%d" % n
    return {
        "ticket_id": ticket_id,
        "status": "created",
        "category": body.category,
        "issue": body.query or "未填写故障描述",
        "message": "报修工单已创建,工单号 %s" % ticket_id,
    }


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run("作业2_ticket_api:app", host="0.0.0.0", port=8778, reload=False)

完整脚本:scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh

完整脚本:scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/作业2_客服意图路由工作流/作业2_启动_ticket.sh

bash
#!/usr/bin/env bash
# 作业 2 · 在 207 上启动模拟「工单报修」FastAPI 服务
set -euo pipefail

DIR="/opt/ticket_api"
PORT="${TICKET_API_PORT:-8778}"
PIDFILE="/tmp/ticket_api.pid"
LOG="/tmp/ticket_api.log"

mkdir -p "$DIR"
pip3 install fastapi uvicorn -q

if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
  echo "ticket_api already running pid=$(cat "$PIDFILE")"
  exit 0
fi

cd "$DIR"
nohup python3 -m uvicorn 作业2_ticket_api:app \
  --host 0.0.0.0 --port "$PORT" >>"$LOG" 2>&1 &
echo $! >"$PIDFILE"
sleep 1
curl -sf "http://127.0.0.1:${PORT}/health" && echo
echo "started on :${PORT}, log=$LOG"

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 1:把工单模拟服务部署到 207 并启动。"""
from __future__ import annotations

import sys
from pathlib import Path

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify

REMOTE_DIR = "/opt/ticket_api"
LOCAL_API = Path(__file__).resolve().parent / "作业2_ticket_api.py"
LOCAL_SH = Path(__file__).resolve().parents[1] / "scripts_bash_cursor" / "作业2_启动_ticket.sh"


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        d.sh("mkdir -p %s" % REMOTE_DIR)
        d.sftp.put(str(LOCAL_API), REMOTE_DIR + "/作业2_ticket_api.py")
        d.sftp.put(str(LOCAL_SH), REMOTE_DIR + "/start.sh")
        d.sh("chmod +x %s/start.sh" % REMOTE_DIR)
        d.sh(
            "if [[ -f /tmp/ticket_api.pid ]]; then "
            "kill $(cat /tmp/ticket_api.pid) 2>/dev/null || true; "
            "rm -f /tmp/ticket_api.pid; fi"
        )
        out, err = d.sh("TICKET_API_PORT=%d bash %s/start.sh" % (C.TICKET_API_PORT, REMOTE_DIR))
        print(out or err)
        r = d.sh(
            "curl -s -X POST 'http://127.0.0.1:%d/ticket' "
            "-H 'Content-Type: application/json' "
            "-d '{\"query\":\"帮我报修设备\",\"category\":\"repair\"}'"
            % C.TICKET_API_PORT
        )
        print("probe:", r[0].strip())
        return 0 if "WO-" in (r[0] or "") else 1
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 2:创建 Workflow 空壳(mode=workflow)。"""
from __future__ import annotations

import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp


def find_app(d: Dify) -> dict | None:
    r = d.get("/apps?page=1&limit=50")
    for item in (r.get("json") or {}).get("data") or []:
        if item.get("name") == C.HOMEWORK2_APP_NAME and item.get("mode") == "workflow":
            return item
    aid = getattr(C, "HOMEWORK2_APP_ID", "") or ""
    if aid:
        for item in (r.get("json") or {}).get("data") or []:
            if item.get("id") == aid:
                return item
    return None


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if found:
            print("already exists app_id=", found.get("id"), "mode=", found.get("mode"))
            print("下一步:运行 作业2_03_同步工作流草稿.py")
            return 0
        body = {
            "name": C.HOMEWORK2_APP_NAME,
            "description": "作业 2:客服意图路由(知识库 / 工单报修 / 人工客服 / 兜底)",
            "mode": "workflow",
            "icon_type": "emoji",
            "icon": "🎧",
            "icon_background": "#E3F2FD",
        }
        r = d.post("/apps", body)
        pp("create workflow app", r)
        j = r.get("json") or {}
        print("app_id", j.get("id"))
        if r.get("code") not in ("200", "201"):
            return 1
        print("下一步:运行 作业2_03_同步工作流草稿.py")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 3:把意图路由 graph 同步到 Workflow 草稿。"""
from __future__ import annotations

import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp
from 作业2_graph import FEATURES, build_graph
from 作业2_02_创建工作流空壳 import find_app


def main() -> int:
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if not found:
            print("找不到应用,请先跑 作业2_02_创建工作流空壳.py")
            return 1
        app_id = found["id"]
        print("app_id", app_id)

        hash_val = None
        r0 = d.get("/apps/%s/workflows/draft" % app_id)
        if r0.get("code") == "200":
            hash_val = (r0.get("json") or {}).get("hash")
            print("existing draft hash", hash_val)

        body = {
            "graph": build_graph(),
            "features": FEATURES,
            "environment_variables": [],
            "conversation_variables": [],
        }
        if hash_val:
            body["hash"] = hash_val

        r = d.post("/apps/%s/workflows/draft" % app_id, body)
        pp("sync draft", r, limit=2000)
        if r.get("code") not in ("200", "201"):
            return 1

        # 再读一遍确认节点数量
        r2 = d.get("/apps/%s/workflows/draft" % app_id)
        g = ((r2.get("json") or {}).get("graph")) or {}
        print("nodes", len(g.get("nodes") or []), "edges", len(g.get("edges") or []))
        print("hash", (r.get("json") or {}).get("hash") or (r2.get("json") or {}).get("hash"))
        print("下一步:运行 作业2_04_验收三句.py")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 步骤 4:三句验收(知识库 / 工单 / 人工)。"""
from __future__ import annotations

import json
import sys

import _bootstrap  # noqa: F401
import _config as C
from dify_api import Dify, pp
from 作业2_02_创建工作流空壳 import find_app


def parse_sse(text: str) -> dict:
    """从 Dify workflow 流式响应里抽出分类名、结束输出、错误。"""
    class_name = ""
    answer = ""
    errors: list[str] = []
    node_titles: list[str] = []
    for raw in text.splitlines():
        line = raw.strip()
        if line.startswith("data:"):
            line = line[5:].strip()
        if not line.startswith("{"):
            continue
        try:
            ev = json.loads(line)
        except json.JSONDecodeError:
            continue
        event = ev.get("event") or ""
        data = ev.get("data") or ev
        title = (data.get("title") or data.get("node_type") or "") if isinstance(data, dict) else ""
        if event in ("node_started", "node_finished") and title:
            node_titles.append("%s:%s" % (event, title))
        if event == "node_finished":
            outputs = data.get("outputs") or {}
            if outputs.get("class_name"):
                class_name = str(outputs.get("class_name"))
            if outputs.get("class_label") and not class_name:
                class_name = str(outputs.get("class_label"))
            if outputs.get("intent"):
                class_name = str(outputs.get("intent"))
            err = data.get("error")
            if err:
                errors.append("%s: %s" % (title or data.get("node_id"), err))
        if event in ("workflow_finished", "workflow_succeeded"):
            outs = (data.get("outputs") or {}) if isinstance(data, dict) else {}
            if outs.get("answer"):
                answer = str(outs.get("answer"))
            elif outs:
                answer = json.dumps(outs, ensure_ascii=False)
        if event == "error":
            errors.append(str(data))
    if not answer:
        # 兜底:全文里找 WO- / 转人工 / 请假
        answer = text[-4000:]
    return {
        "class_name": class_name,
        "answer": answer,
        "errors": errors,
        "node_titles": node_titles[-30:],
    }


def run_one(d: Dify, app_id: str, query: str) -> dict:
    r = d.post(
        "/apps/%s/workflows/draft/run" % app_id,
        body={"inputs": {"query": query}, "files": []},
        extra="-m 420 --max-time 420",
        t=450,
    )
    parsed = parse_sse(r.get("text") or "")
    parsed["http"] = r.get("code")
    parsed["raw_head"] = (r.get("text") or "")[:800]
    return parsed


def main() -> int:
    cases = [
        (
            C.HOMEWORK2_PROBE_KB,
            "知识库问答",
            ("请假", "知识库", "制度", "流程", "hr", "行政"),
            ("知识库检索", "生成回答"),
        ),
        (
            C.HOMEWORK2_PROBE_TICKET,
            "工单报修",
            ("WO-", "工单", "报修"),
            ("调用工单API", "返回工单号"),
        ),
        (
            C.HOMEWORK2_PROBE_HUMAN,
            "人工客服",
            ("转接", "人工", "投诉"),
            ("转人工提示",),
        ),
    ]
    d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
    try:
        d.login()
        found = find_app(d)
        if not found:
            print("找不到应用")
            return 1
        app_id = found["id"]
        print("app_id", app_id)
        failed = 0
        for query, expect_cls, markers, path_nodes in cases:
            print("\n======== 问句:", query, "期望分类:", expect_cls)
            parsed = run_one(d, app_id, query)
            print("http", parsed["http"])
            print("class_name", parsed["class_name"])
            print("errors", parsed["errors"][:3])
            print("nodes", parsed["node_titles"])
            ans = parsed["answer"] or ""
            print("answer_head", ans[:500].replace("\n", " / "))
            hits = [m for m in markers if m.lower() in ans.lower() or m in (parsed["class_name"] or "")]
            cls_ok = expect_cls in (parsed["class_name"] or "") or expect_cls in ans
            path_ok = any(n in str(parsed["node_titles"]) for n in path_nodes)
            ok = parsed["http"] in ("200",) and (cls_ok or path_ok) and (len(hits) >= 1 or cls_ok)
            if not parsed["errors"] and ok:
                print("[PASS]")
            else:
                print("[FAIL] hits=", hits, "cls_ok=", cls_ok, "path_ok=", path_ok)
                failed += 1
                print("raw_head", parsed["raw_head"][:800])
        if failed:
            print("\n[FAIL] %d / %d 未通过" % (failed, len(cases)))
            return 1
        print("\n[PASS] 三句验收均走对应路径。")
        return 0
    finally:
        d.close()


if __name__ == "__main__":
    sys.exit(main())

6.1 问题速查表

#现象根因解决方案
1GET .../workflows/draft 404新 Workflow 尚无草稿先 POST .../workflows/draft 提交 graph
2question-classifier 失败 could not find json blockQwen3.6 reasoning 输出非 json改用 LLM+Code+IF/ELSE(本交付方案)
3HTTP 节点超时URL 写 localhost:8778改为 http://192.168.110.207:8778
4知识库路径空答dataset_ids 错 / 库未 embed核对 _config.DS_ID
5意图总走「其他」LLM 输出带思考链Code 节点剥离 ;prompt 要求只输出四字标签
6sync draft 409 / hash 冲突控制台同时改图先 GET draft 取 hash 再 POST

6.2 question-classifier 为何在本环境踩坑

官方模板要求 Assistant 回复 纯 JSON(含 category_id / category_name)。实测 Qwen3.6-27B 在 vLLM 上常带 或自然语言,解析器 parse_json_markdown 找不到代码块即失败,节点状态 failed,后续分支不执行。

两种修法:

  1. 换模型 / 关 thinking / 调小 max_tokens(仍可能不稳定)
  2. LLM 输出中文标签 + Code 规范化 + IF/ELSE(本作业采用,已三句 PASS)

若课程强制 UI 必须用 question-classifier,可在 Dify 工作室手工建分类器节点,但验收脚本仍以本仓库 graph 为准。


第七部分 验收清单


*文档与 207 实战环境同步;graph 变更后重跑 作业2_03 + 作业2_04。*