Dify 韧性验证实验(02):契约与消费一致性——多应用协作时契约变了如何第一时间发现?
1. 业务场景
先讲一个我们实际遇到的场景。
一家做客服工单 SaaS 的公司,运维同学半夜接到「支付网关报错」的告警,把故障描述粘进排障助手,系统自动诊断、给出排查建议,并通知运维值班群。开发阶段,通知功能用的是 mock 服务;上线前,要切换成真实消息 API。切换那天,最怕的就是「看着一切正常,其实收件人传错了、返回结构变了」——排障建议发给了错误的人,诊断结论却是「通知成功」。
我们第一次接这类需求时,第一反应是「mock 和真实 API
都是通知,能有多大差别」。真正动手才发现——差别全藏在字段名和返回结构里:mock
用 to 作收件人,真实 API 用
recipient,切换后下游全部错位,应用却不报一个错。从那以后,凡是「开发期
mock、上线前切真实依赖」的集成,我们都先做一遍契约核对。
这不是个例。任何「开发期用 mock、上线前切真实依赖」的集成都是这个模式:支付回调、短信通道、订单查询、企业微信通知——切换前后应用都能跑,只是悄悄传错字段或消费错结构,不对比根本发现不了。
2. 场景痛点
这个流程的痛点,在排障助手上体现得最直接:
- 参数错位无感:mock 用
to作收件人字段,真实 API 用recipient——切换后下游全部错位,通知发给了错误的人,但应用不报错,用户毫不知情。 - 返回结构变化静默:mock 返回
{ok, message_id},真实 API 返回另一种结构——下游解析代码要么被迫重写,要么解析出空值,通知结果形同虚设。 - 模型无视工具结果瞎编:知识库说「X 支持 A」,LLM 回答「X 不支持 A」——检索到了却答错,运维拿着错误结论去处理故障,越处理越乱。
- 切换前后不对比发现不了:两边的流程都能跑通,只有逐字段核对才能暴露差异——问题最阴险的地方就在这里。
本质上,契约问题最阴险的地方在于——切换前后应用都能跑,只是悄悄传错字段或消费错结构,不对比根本发现不了。
3. 方案:为什么是契约验证
Dify 工作流里验证工具层契约最系统的方法,就是契约三件套:参数契约、返回契约、消费一致性,逐项对照 mock 与真实 API。
选它的理由:
- 契约三件套可落地:mock 与真实 API 的参数名/类型/必填一致、返回结构一致、消费结果一致——切换前先逐项核对,问题在切换前暴露而不是上线后;
- 平台原生可编排:mock code 节点与真实 http-request 双分支并存,一次运行两个分支都能验证,切换前后的输出直接对比;
- 矛盾可自动检测:
cd_contract节点对比回答与检索结果,输出contract_ok布尔值 + 定位关键字——「检索到了但回答与检索结果矛盾」这类 P2 缺陷不用肉眼找。
这篇文章我们就用它搭一个「排障助手」应用:用户描述故障 → 诊断链 → 输出排查建议 + 通知运维,mock 与真实 API 双分支,逐项验证契约不被破坏。
4. 整体架构
链路很清晰:入口收故障描述 → 参数提取 → 诊断 LLM → 通知 API(mock/真实双分支)→ 矛盾检测 → 输出报告。关键设计是 mock 与真实 API 走同一套参数映射与返回解析——契约差异在切换前就被暴露,而不是上线后爆雷。
5. 模块设计
5.1 契约三件套(本实验核心)
- 参数契约:mock 与真实 API
的参数名/类型/必填一致——收件人
to/ 标题title/ 正文content。不能 mock 用to、真实用recipient,切换后下游全部错位。 - 返回契约:mock 返回
{ok: true, message_id}与真实 API 返回结构一致——下游解析代码不因结构变化重写。 - 消费一致性:诊断报告引用的知识库信息 vs 实际检索结果一致(矛盾检测)。
5.2 参数映射(code 节点 json.dumps 组装)
含自由文本的请求体必须用 code 节点组装(104 实测):直接拼 body.data 会被自由文本里的引号破坏 JSON:
def main(to: str, title: str, content: str) -> dict:
import json
body = {"to": to, "title": title, "content": content}
return {"body_data": json.dumps(body, ensure_ascii=False)}5.3 检索→回答矛盾检测(cd_contract)
知识库说「X 支持 A」,LLM 回答「X 不支持 A」→ 抓消费矛盾。输出
contract_ok 布尔值 + 定位关键字,矛盾时下游可见:
def main(answer: str, kb_refs: str) -> dict:
# 简化:回答中引用的关键事实必须在检索结果里出现
conflict = False
for kw in ["支持", "不支持", "免费", "收费"]:
if kw in answer and kw not in kb_refs:
conflict = True
break
return {"contract_ok": not conflict,
"conflict_keyword": kw if conflict else ""}5.4 mock 表外分支类型一致
mock 数据表外默认分支的数值字段返回 0/None 而非
"未知"(D102-P2-01 教训——"未知" > 0
崩溃):
def main(mock_key: str) -> dict:
table = {"a": {"ok": True, "message_id": "mock-81512"}}
row = table.get(mock_key, {"ok": False, "message_id": None}) # 数值字段不返回字符串
return row6. 运行验证
| 用例 | 输入要点 | 预期 | 结果 |
|---|---|---|---|
| mock 模式 | fault_desc=API 报错,通知走 mock | 通知成功(mock-81512)[mock],报告含诊断/通知/一致性检查三段 | 通过(实测) |
| real 模式 | 同输入,通知走 KV /echo 模拟真实 API | 通知成功(real-054418)[真实API],回显 to/title/content 正确 | 通过(实测) |
| 契约对比 | mock vs real 两分支输出 | 返回结构一致 {ok, message_id},下游 cd_report 同一解析逻辑未改 | 通过(实测,参数契约 + 返回契约成立) |
| 矛盾检测 | 知识库说「X 支持 A」,LLM 答「X 不支持 A」 | contract_ok=false + 定位关键字 | 通过(实测,正常时输出「未发现矛盾」) |
| 参数错位用例 | 模拟传参错位(收件人=正文) | 参数映射错误被暴露 | 通过(实测,回显核对 to 字段) |
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| mock 表外类型不一致 | 表外返回 "未知"(str)→ 类型比较崩溃 | mock 表外数值字段返回 0/None,不返回字符串(实测,102-06) |
| code 输出类型不匹配 | 声明 string 返回 list → workflow failed | 输出类型声明 = return 值类型,数组 json.dumps(实测,104-01) |
| body 内嵌 JSON 引号陷阱 | 自由文本直接拼 body.data → JSON 破坏 400 | 请求体用 code 节点 json.dumps 组装(实测,104) |
| 检索回答矛盾 | 模型无视工具结果瞎编(消费一致性缺陷,P2) | cd_contract 矛盾检测 + contract_ok 布尔输出(实验文档设计约束) |
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-105-02:契约与消费一致性.md
- 源码(可直接导入):dify105_02_排障助手.yml
- 全部实验文档目录:dify-105/experiments
- 全部源码目录:dify-105/dsl
文章聚焦核心配置与采坑点;实验的完整分步操作(节点搭建/参数表/调试指引)见实验文档原文。
- Dify 韧性验证实验(01):故障注入与降级链——如何用故障注入验证 AI 应用的降级链?
- Dify 韧性验证实验(02):契约与消费一致性——多应用协作时契约变了如何第一时间发现?
- Dify 韧性验证实验(03):多轮记忆边界——对话记忆在哪些场景会失效?
- Dify 韧性验证实验(04):安全对抗——如何给 AI 应用做安全对抗测试?
- Dify 韧性验证实验(05):并发与可靠性——高并发下 AI 应用如何保证可靠?
- Dify 韧性验证实验(06):全链路追踪——一次请求如何在 Dify 中被完整追踪?
- Dify 韧性验证实验(07):六模块综合验收——AI 应用上线前如何做六维度验收?
- Dify 韧性验证实验(08):上线后回归——应用上线后如何持续回归验证?