作业 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。
目录
- 做完长什么样
- 第一部分 原理:Workflow 意图路由怎么跑
- 第二部分 前置条件
- 第三部分 详细操作步骤
- 第四部分 CPU 与 GPU 在本作业中的分工
- 第五部分 完整代码程序
- 第六部分 本次实战问题与解决方案
- 第七部分 验收清单
做完长什么样
| 项 | 本环境实测值 |
|---|---|
| Workflow 名称 | 客服意图路由工作流 |
| app_id | f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf |
| 工单模拟 API | http://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/agent | mode=workflow,创建口 POST http://127.0.0.1/console/api/apps |
| 路由方式 | 模型自行决定调哪个 tool | 图里 显式分支(本作业:LLM + 代码 + IF/ELSE) |
| 知识库 | 本作业未用 | knowledge-retrieval 节点 |
| 外部 HTTP | OpenAPI 自定义工具 | http-request 节点 |
| 验收 API | POST http://127.0.0.1/console/api/agent/01a0189c-7d7e-742f-83d1-74c5c16878da/chat-messages | POST http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft/run |
1.2 本作业图结构(与题目一致)
用户输入(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 完整执行流程(逐步)
【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第二部分 前置条件
| # | 条件 | 检查 |
|---|---|---|
| 1 | Dify 1.16 可登录 | http://192.168.110.207 |
| 2 | vLLM Qwen3.6-27B 可用 | 专题一 / 五 |
| 3 | 行政知识库已建且可检索 | DS_ID 见 _config.py |
| 4 | Xinference bge-m3 + rerank 在跑 | 专题二 / 十三 |
| 5 | 8778 端口未被占用 | 8777 已被作业 1 占用 |
| 6 | Workflow 草稿需 POST graph 初始化 | GET .../workflows/draft 首次 404 正常 |
第三部分 详细操作步骤
3.0 一键顺序(脚本)
/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 | 三句话分别走知识库 / 工单 / 转人工 |
用户输入
→ Workflow「开始」→ LLM 意图识别(四类)
├─ 产品咨询 → 知识库检索 → LLM 回答
├─ 报修工单 → HTTP 调 :8789 创建工单
├─ 转人工 → 固定回复 + 人工队列
└─ 其它 → 兜底话术各脚本四要素速查(graph/curl 细节见下方 3.1~3.4):
| 脚本 | 干什么 | 触发什么 | 成功标志 | 典型失败 |
|---|---|---|---|---|
01_部署工单模拟服务 | 启动 :8789 工单 API | uvicorn + POST /tickets 探针 | [OK] + health | 8789 占用 |
02_创建工作流空壳 | POST /apps mode=workflow | 仅建空应用 | already exists / 201 | 误用 agent 模式 |
03_同步工作流草稿 | POST 整图 graph+features | 14 节点写入 draft | [OK] draft synced | 404 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。
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:
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 空壳:
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。手工核对草稿是否存在:
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):
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(不需要登录):
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
#!/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
#!/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"探针
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
#!/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
#!/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 到草稿
理论
- 新建 Workflow 没有草稿:
GET http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft→404 draft_workflow_not_exist - 必须用
POST http://127.0.0.1/console/api/apps/f0e82c66-e324-4df7-a1fe-44d6ce6ec7cf/workflows/draft提交graph+features才会生成草稿(graph 很长,用作业2_03) - 再次同步时带上上次返回的
hash,避免并发覆盖冲突
graph 定义(完整)
完整程序:/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py
#!/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
#!/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
#!/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-27B | 116 GPU · vLLM | 意图识别 LLM + 知识库生成 LLM(每问 1~2 次) |
| bge-m3 | 207 CPU · Xinference | 知识库路径向量检索 |
| bge-reranker-v2-m3 | 207 CPU · Xinference | 知识库 Top-K 重排(graph 里已开 rerank) |
| FastAPI 工单 | 207 CPU | 仅工单分支触发一次 HTTP |
| Weaviate | 207 | 存向量 |
| Dify worker | 207 | 跑 workflow 节点 |
4.1 性能提示
- 知识库路径最慢:意图 LLM + 检索 + rerank + 生成 LLM,单次约 20~60s(视 vLLM 排队而定,见专题十四)。
- 工单 / 人工 / 兜底路径:通常 10~20s(一次意图 LLM + 模板或 HTTP)。
- 意图 LLM 已设
enable_thinking: false、temperature: 0,减少 JSON/标签解析失败。
4.2 未来有 GPU 时
| 项 | 现状 | 可选演进 |
|---|---|---|
| Embedding/Rerank | 207 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.sh | 207 启动脚本 |
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_graph.py | Workflow graph + features |
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_01_部署工单模拟服务.py | 部署 + 探针 |
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_02_创建工作流空壳.py | POST http://127.0.0.1/console/api/apps(mode=workflow) |
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_03_同步工作流草稿.py | POST http://127.0.0.1/console/api/apps/f0e82c66-.../workflows/draft |
/opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py | POST .../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
#!/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
#!/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
#!/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
#!/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
#!/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
#!/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
#!/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 问题速查表
| # | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 1 | GET .../workflows/draft 404 | 新 Workflow 尚无草稿 | 先 POST .../workflows/draft 提交 graph |
| 2 | question-classifier 失败 could not find json block | Qwen3.6 reasoning 输出非 json | 改用 LLM+Code+IF/ELSE(本交付方案) |
| 3 | HTTP 节点超时 | URL 写 localhost:8778 | 改为 http://192.168.110.207:8778 |
| 4 | 知识库路径空答 | dataset_ids 错 / 库未 embed | 核对 _config.DS_ID |
| 5 | 意图总走「其他」 | LLM 输出带思考链 | Code 节点剥离 ;prompt 要求只输出四字标签 |
| 6 | sync 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,后续分支不执行。
两种修法:
- 换模型 / 关 thinking / 调小 max_tokens(仍可能不稳定)
- LLM 输出中文标签 + Code 规范化 + IF/ELSE(本作业采用,已三句 PASS)
若课程强制 UI 必须用 question-classifier,可在 Dify 工作室手工建分类器节点,但验收脚本仍以本仓库 graph 为准。
第七部分 验收清单
- [ ]
curl 8778/health返回ok - [ ]
作业2_03同步后 draft 含 14 节点 - [ ] 「怎么请假」→ 回答含请假/制度相关内容,节点含 知识库检索
- [ ] 「帮我报修设备」→ 回答含
WO-工单号 - [ ] 「我要投诉」→ 回答含 转接人工 类文案
- [ ]
/usr/bin/python3 /opt/workbuddy/scripts_cursor/作业2_客服意图路由工作流/作业2_04_验收三句.py→[PASS]
*文档与 207 实战环境同步;graph 变更后重跑 作业2_03 + 作业2_04。*