这篇做一个工单分诊工作流:输入一段用户反馈,模型输出
{"category": "账单|故障|功能建议|其他", "summary": "..."}。 工作流以 DSL(YAML)文件的形式放进 git,用脚本导入、发布、生成 API Key,再通过公开的 Service API/v1/workflows/run调用(blocking 和 streaming 两种模式都做)。 另外配一个 8 条标注数据的评测脚本,每次改提示词后先跑一遍。
先看清成品(box 上真实运行,模型是本地 qwen2.5 1.5B)
- 导入状态
completed,发布success,拿到app-开头的 API Key。- blocking 调用约 1 秒返回,streaming 收到
workflow_started、node_started、text_chunk、node_finished、workflow_finished五种事件。- 评测准确率:初版 6/8,加分类定义后反而降到 5/8,加 few-shot 示例后连续两次 8/8。
- pytest 3 项通过。
合规提醒:Dify 采用附加条件的 Apache 2.0 许可证:未经书面授权不得用源码运营多租户服务,也不得移除或修改前端的 LOGO 和版权信息,详见仓库里的 LICENSE。自托管时,数据都留在你自己的机器上,但如果接入了云端模型,提示词和数据会发送给对应的服务商。管理员密码、应用 API Key、外部知识库 Key 都不要提交进仓库。本文不列 Dify Cloud 的价格,以官网为准。
关于控制台 API:初始化、装插件、导入 DSL 这几步用的是控制台前端调用的
/console/api/*接口。它不是官方公开的稳定 API,本文按 1.17.1 的源码确认了路径和字段,升级后可能变化。同样的操作都可以在网页控制台里点完,脚本只是为了让环境可以重复搭建。对外集成请用公开的/v1Service API(B 篇)。
运行说明:全部在 box 上用 A 篇的本机 Dify 实际运行。8 条评测数据太少,8/8 不代表线上准确率,只能说明改动方向是对的。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
ticket_triage.yml |
工作流 DSL:开始 → LLM 分诊 → 结束 |
deploy_app.py |
导入 → 发布 → 生成 API Key,写入 .env.app(权限 600) |
update_app.py |
用同一份 DSL 覆盖已有应用并重新发布,API Key 不变 |
triage_client.py |
blocking 和 streaming 两种模式的调用,以及输出解析 |
eval_set.jsonl / eval_triage.py |
标注集和评测脚本,低于阈值时退出码为 1 |
test_triage.py |
离线测试输出解析 |
需要准备哪些环境?
A 篇装好的本机 Dify(或者任意一个 Dify 1.17 实例)和 Python 3.10 以上。脚本只用标准库,测试需要 pytest。
文件结构
55-dify-B-src/
├── ticket_triage.yml
├── dify_console.py # 同 A 篇
├── deploy_app.py
├── update_app.py
├── triage_client.py
├── eval_set.jsonl
├── eval_triage.py
└── test_triage.py
第 1 步:DSL
DSL 的结构参考了 Dify 仓库里的测试样例 api/tests/fixtures/workflow/basic_llm_chat_workflow.yml。模型的 provider 要写完整的插件路径 langgenius/ollama/ollama:
app:
description: '工单分诊:把用户反馈归类并给出一句话摘要'
icon: 🎫
icon_background: '#FFEAD5'
mode: workflow
name: ticket-triage
use_icon_as_answer_icon: false
dependencies: []
kind: app
version: 0.7.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
enabled: false
opening_statement: ''
retriever_resource:
enabled: false
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
graph:
edges:
- data: {isInIteration: false, isInLoop: false, sourceType: start, targetType: llm}
id: start-to-llm
source: start_node
sourceHandle: source
target: llm_node
targetHandle: target
type: custom
zIndex: 0
- data: {isInIteration: false, isInLoop: false, sourceType: llm, targetType: end}
id: llm-to-end
source: llm_node
sourceHandle: source
target: end_node
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
desc: ''
title: 开始
type: start
variables:
- label: 工单内容
max_length: 2000
options: []
required: true
type: paragraph
variable: ticket
height: 90
id: start_node
position: {x: 30, y: 227}
positionAbsolute: {x: 30, y: 227}
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
desc: ''
title: 分诊
type: llm
model:
provider: langgenius/ollama/ollama
name: qwen2.5-local:1.5b
mode: chat
completion_params:
temperature: 0
prompt_template:
- role: system
text: |-
你是客服工单分诊助手。只输出一行 JSON,不要输出其他内容。
格式:{"category": "账单|故障|功能建议|其他", "summary": "不超过30字的中文摘要"}
分类定义(按顺序判断):
- 账单:涉及扣费、退款、价格、续费、发票、订阅金额,即使用户在抱怨也归为账单
- 故障:已有功能无法正常使用,如闪退、报错、无法登录
- 功能建议:希望新增或改进功能
- 其他:以上都不是
示例:
输入:我的会员怎么自动扣了年费? 输出:{"category": "账单", "summary": "会员被自动扣年费"}
输入:收据上的金额不对。 输出:{"category": "账单", "summary": "收据金额有误"}
输入:上传图片一直转圈。 输出:{"category": "故障", "summary": "上传图片卡住"}
输入:建议增加批量删除。 输出:{"category": "功能建议", "summary": "希望支持批量删除"}
- role: user
text: '{{#start_node.ticket#}}'
vision: {enabled: false, configs: {variable_selector: []}}
memory: {enabled: false, window: {enabled: false, size: 50}}
context: {enabled: false, variable_selector: []}
structured_output: {enabled: false}
retry_config: {enabled: false, max_retries: 1, retry_interval: 1000}
height: 90
id: llm_node
position: {x: 334, y: 227}
positionAbsolute: {x: 334, y: 227}
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
desc: ''
outputs:
- value_selector: [llm_node, text]
value_type: string
variable: result
title: 结束
type: end
height: 90
id: end_node
position: {x: 638, y: 227}
positionAbsolute: {x: 638, y: 227}
sourcePosition: right
targetPosition: left
type: custom
width: 244
viewport: {x: 0, y: 0, zoom: 0.7}
第 2 步:导入、发布、生成 Key
"""导入 DSL -> 发布 -> 生成应用 API Key(写入 .env.app,不打印完整 Key)。"""
import os, pathlib, sys
from dify_console import Console
dsl = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "ticket_triage.yml").read_text(encoding="utf-8")
c = Console(); c.login(os.environ["DIFY_EMAIL"], os.environ["DIFY_PASSWORD"])
st, imp = c.call("POST", "/apps/imports", {"mode": "yaml-content", "yaml_content": dsl})
print("导入:", st, imp.get("status"), imp.get("error") or "")
if imp.get("status") == "pending": # DSL 版本比服务端旧/新时需要二次确认
st, imp = c.call("POST", f"/apps/imports/{imp['id']}/confirm")
print("确认导入:", st, imp.get("status"))
if imp.get("status") not in ("completed", "completed-with-warnings"):
raise SystemExit(f"导入失败: {imp}")
app_id = imp["app_id"]
st, js = c.call("POST", f"/apps/{app_id}/workflows/publish", {"marked_name": "v1", "marked_comment": "from git"})
print("发布:", st, js.get("result") if isinstance(js, dict) else js)
st, key = c.call("POST", f"/apps/{app_id}/api-keys")
print("API Key:", st, key["token"][:8] + "…")
pathlib.Path(".env.app").write_text(f"DIFY_APP_ID={app_id}\nDIFY_APP_KEY={key['token']}\n")
os.chmod(".env.app", 0o600)
"""用同一份 DSL 覆盖已有应用(app_id 来自 .env.app),然后重新发布。API Key 不变。"""
import os, pathlib
from dify_console import Console
env = dict(l.strip().split("=", 1) for l in open(".env.app") if "=" in l)
dsl = pathlib.Path("ticket_triage.yml").read_text(encoding="utf-8")
c = Console(); c.login(os.environ["DIFY_EMAIL"], os.environ["DIFY_PASSWORD"])
st, imp = c.call("POST", "/apps/imports", {"mode": "yaml-content", "yaml_content": dsl, "app_id": env["DIFY_APP_ID"]})
print("覆盖导入:", st, imp.get("status"), imp.get("error") or "")
st, js = c.call("POST", f"/apps/{env['DIFY_APP_ID']}/workflows/publish", {"marked_name": "v2", "marked_comment": "分类定义"})
print("发布:", st, js)
第 3 步:调用 Service API
官方文档说明 /v1/workflows/run 只能运行已发布的工作流,支持 blocking(一次性返回)和 streaming(SSE 事件流)两种模式:
"""调用已发布的 Dify 工作流:/v1/workflows/run(blocking 与 streaming 两种模式)。"""
import json, os, sys, urllib.request, urllib.error
BASE = os.environ.get("DIFY_API_BASE", "http://localhost/v1")
UA = "binbho-tutorial/1.0"
def load_key():
if os.environ.get("DIFY_APP_KEY"):
return os.environ["DIFY_APP_KEY"]
for line in open(".env.app", encoding="utf-8"):
if line.startswith("DIFY_APP_KEY="):
return line.split("=", 1)[1].strip()
raise SystemExit("缺少 DIFY_APP_KEY")
def _req(body, key):
return urllib.request.Request(BASE + "/workflows/run", data=json.dumps(body).encode(), method="POST",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": UA})
def parse_result(text):
"""模型偶尔会包一层 ```json,先剥掉再解析;失败就返回 None 交给人工。"""
t = text.strip()
if t.startswith("```"):
t = t.strip("`").removeprefix("json").strip()
try:
obj = json.loads(t)
except ValueError:
return None
if obj.get("category") not in {"账单", "故障", "功能建议", "其他"}:
return None
return obj
def run_blocking(ticket, key, user="demo-user"):
body = {"inputs": {"ticket": ticket}, "response_mode": "blocking", "user": user}
try:
with urllib.request.urlopen(_req(body, key), timeout=180) as r:
data = json.load(r)["data"]
except urllib.error.HTTPError as e:
raise SystemExit(f"HTTP {e.code}: {e.read().decode()[:300]}")
return data
def run_streaming(ticket, key, user="demo-user"):
body = {"inputs": {"ticket": ticket}, "response_mode": "streaming", "user": user}
events = []
with urllib.request.urlopen(_req(body, key), timeout=180) as r:
for raw in r:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
ev = json.loads(line[5:])
events.append(ev["event"])
if ev["event"] == "workflow_finished":
return events, ev["data"]
raise RuntimeError(f"流在 workflow_finished 之前结束: {events}")
if __name__ == "__main__":
key = load_key()
ticket = sys.argv[1] if len(sys.argv) > 1 else "上个月被重复扣费两次,请退款。"
d = run_blocking(ticket, key)
print("blocking:", d["status"], f"{d['elapsed_time']:.1f}s", "tokens", d["total_tokens"])
print(" 原文:", d["outputs"]["result"])
print(" 解析:", parse_result(d["outputs"]["result"]))
evs, d2 = run_streaming("App 打开就闪退,iOS 18。", key)
print("streaming 事件:", sorted(set(evs)))
print(" 解析:", parse_result(d2["outputs"]["result"]))
第 4 步:评测与测试
{"ticket": "上个月被重复扣费两次,请退款。", "label": "账单"}
{"ticket": "发票抬头开错了,能重开吗?", "label": "账单"}
{"ticket": "App 打开就闪退,iOS 18。", "label": "故障"}
{"ticket": "登录时一直提示网络错误,但网络是好的。", "label": "故障"}
{"ticket": "希望能支持导出成 Excel。", "label": "功能建议"}
{"ticket": "能不能加一个深色模式?", "label": "功能建议"}
{"ticket": "你们公司地址在哪里?", "label": "其他"}
{"ticket": "续费价格怎么比去年贵了?", "label": "账单"}
"""对已发布工作流跑标注集,打印准确率和错例。temperature=0,但小模型仍可能波动,建议跑 2 次。"""
import json, sys
from triage_client import load_key, run_blocking, parse_result
key = load_key()
rows = [json.loads(l) for l in open("eval_set.jsonl", encoding="utf-8")]
ok = 0
for r in rows:
out = parse_result(run_blocking(r["ticket"], key)["outputs"]["result"])
got = out["category"] if out else "解析失败"
ok += got == r["label"]
if got != r["label"]:
print(f" ✗ {r['ticket']} 期望 {r['label']} 实际 {got}")
print(f"准确率 {ok}/{len(rows)}")
sys.exit(0 if ok / len(rows) >= float(sys.argv[1] if len(sys.argv) > 1 else 0.75) else 1)
from triage_client import parse_result
def test_plain_json():
assert parse_result('{"category": "账单", "summary": "重复扣费"}')["category"] == "账单"
def test_fenced_json():
assert parse_result('```json\n{"category": "故障", "summary": "闪退"}\n```')["category"] == "故障"
def test_reject_unknown_category_and_garbage():
assert parse_result('{"category": "投诉", "summary": "x"}') is None
assert parse_result("这是账单问题") is None
第 5 步:运行
$ python deploy_app.py
导入: 200 completed-with-warnings ← 首次运行时 DSL 还写的是 0.3.1,见调试示例;改成 0.7.0 后变为 completed
发布: 200 success
API Key: 201 app-l9Ju…
$ python triage_client.py
blocking: succeeded 1.0s tokens 92
原文: {"category": "故障", "summary": "上个月被重复扣费两次,请求退款。"}
解析: {'category': '故障', 'summary': '上个月被重复扣费两次,请求退款。'}
streaming 事件: ['node_finished', 'node_started', 'text_chunk', 'workflow_finished', 'workflow_started']
解析: {'category': '故障', 'summary': 'App打开闪退,iOS 18'}
(上面是初版提示词的真实输出。重复扣费被分到了“故障”,这就是下面调试示例要解决的问题。)
错误调用的真实返回:
# Key 错误
{"code":"unauthorized","message":"Access token is invalid","status":401}
# 少了必填输入 ticket
{"code":"invalid_param","message":"ticket is required in input form","status":400}
调试示例
分类不准:加了规则反而更差。 初版评测结果是 6/8,两条账单类工单被分错(“重复扣费”被分成故障,“续费变贵”被分成功能建议)。
- 第一次修改:在 system 提示词里加了四类的定义,并强调“抱怨扣费也归为账单”。重新导入、发布后跑了两次,都是 5/8,连“发票抬头开错”也被分成了“其他”。我先确认了改动确实已经生效:发布版本的
prompt_template里能查到“分类定义”。所以问题不在发布,而是这个 1.5B 的小模型对长篇规则描述的理解有限。 - 第二次修改:保留定义,再加 4 条 few-shot 示例,示例内容特意和评测集不同,避免泄题。连续两次评测都是 8/8。
结论:对小模型来说,给示例比写规则管用。改提示词一定要用评测集验证,不能凭感觉。
导入状态一直是 completed-with-warnings,但 warnings 是空数组。 返回里有 "current_dsl_version": "0.7.0", "imported_dsl_version": "0.3.1",说明状态来自 DSL 版本差异(我照抄样例写的是 0.3.1)。把 version 改成 0.7.0 后,状态变成 completed。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 401 Access token is invalid | Key 错误,或者用了控制台的 Cookie | 用应用 API Key,格式为 Bearer app-... |
400 xxx is required in input form |
inputs 里缺少开始节点的必填变量 |
变量名要和 DSL 里的 variable 一致 |
| 改了 DSL 但调用结果没变 | 只导入了,没有发布 | 导入后调用 publish 接口 |
| 输出带 ```json 包裹,解析失败 | 模型的输出习惯 | 解析前先剥掉代码块;解析失败就转人工 |
pending 状态 |
DSL 版本比服务端新 | 调用 /apps/imports/<id>/confirm 确认,或者改正 version |
模板规则
- 工作流以 DSL 的形式进 git,导入、发布都用脚本完成,环境之间才能保持一致。
- 模型输出一律先校验再使用,分类值要在白名单里。
- 每次改提示词都先跑评测集,低于阈值就不发布。
项目资源
| 用途 | 地址 |
|---|---|
| Dify 源码(Docker Compose 在 docker/ 目录) | https://github.com/langgenius/dify |
| Dify 文档索引(llms.txt) | https://docs.dify.ai/llms.txt |
| 运行工作流 API | https://docs.dify.ai/en/api-reference/workflow-runs/run-workflow |
| 工作流事件流 | https://docs.dify.ai/en/api-reference/workflow-runs/stream-workflow-events |
| DSL 样例(仓库测试文件) | https://github.com/langgenius/dify/blob/1.17.1/api/tests/fixtures/workflow/basic_llm_chat_workflow.yml |
DSL、发布脚本、评测集都放在一个仓库里,改提示词时有记录,跑完评测才发布。