1. 这个需求是怎么来的:SQLBot 输出的“野”与“乱”
我前阵子被拉去收拾一个 Dify 里的老应用,说穿了就是一个 SQLBot:用户在对话框里用自然语言提问,Bot 负责把问题变成 SQL、查数据库、然后返回结果。听起来没什么技术含量,真正动起手来才发现,问题全卡在“返回结果”这一步。
当时的需求也很直接——下游有一个数据看板系统,它通过 API 调用这个 SQLBot,要求拿到的是纯 JSON。比如 {"status":"success","data":[...],"total":123} 这种东西。可是原生 SQLBot 的回答往往是这样的:
“您查询的本月订单总额是 128,432 元,与上月相比增长了 12.3%。”
这段文案给真人看没什么问题,可要是让程序去解析,那基本上是无解的。更离谱的还有带 Markdown 表格的、用 ```json 代码块包起来的、甚至把 SQL 和解释混在一起输出的。后来我干脆把“dify sqlbot 输出内容转 json”这桩事当成一个小型改造项目来做,前后折腾了三天,把提示词方案、工作流代码节点方案、schema 固定方案都试了一遍,整理成这篇实操记录。
1.1 SQLBot 的两种输出路径,必须分开看
Dify 里创建一个 SQLBot,本质上是搭了一个 Agent 应用,它靠着 LLM 做意图理解、SQL 生成,再调用数据库工具执行查询。它有两种典型的输出路径:
- 对话路径:用户直接在聊天窗口提问,答案以自然语言呈现,用于知识库问答、报表分析、日常查询。这种情况下没人会要求 JSON,反而希望回答越像人话越好。
- 程序路径:外部系统调用 Dify 的 API,或者在工作流里把 SQLBot 作为中间节点,输出不是给人看的,而是给下一个节点或下游服务“吃”的。
很多人在 Dify 里折腾了半天 SQLBot,结果只在对话界面试了“看起来能回答”,一接 API 就露馅,原因就是没区分这两条路径。程序路径必须有一个稳定的数据契约,而 JSON 就是最通用的契约形式。
1.2 我见过的“失控”输出,你八成也遇到过
把 SQLBot 的问题汇总一下,其实来来回回就那么几类:
| 现象 | 具体表现 | 对程序的影响 |
|---|---|---|
| 解释性文字混入 | “我已经查完了,结果如下:” | JSON 解析直接失败 |
| Markdown 表格输出 | 带 ` | 和---` 分隔符 |
| 代码块包裹 | 输出被 ```json 包围 | 必须先剥离标记 |
| 字段名不稳定 | 有时 data,有时 result |
下游取不到数据 |
| 数字格式化污染 | 128,432 这种千分位 |
类型转换出错 |
| 空值处理不一致 | 有时 null,有时空字符串 |
下游逻辑错乱 |
| 多轮对话后格式跑偏 | 第一轮还正常,第二轮开始写小作文 | 无法保证稳定消费 |
一句话总结:LLM 擅长生成“看起来合理”的文本,而不是“结构严格正确”的数据。它输出文本时不需要一个 schema,只有当你在提示词和代码层都做了强约束,JSON 才有可能是稳定的。
1.3 核心矛盾:展示型输出 vs 结构型输入
我们之所以觉得 SQLBot 难搞,本质上是模型的训练目标和程序的需求不一致。LLM 的训练目标是生成流畅、自然的文本,它天然地倾向于在回答前面加一句“好的,我来帮您查询”,也天然地喜欢把结果渲染成表格,因为那更符合人类阅读习惯。
但程序不关心你说话好不好听。程序只关心 json.loads() 能不能成功、data[0].id 是不是存在。所以我们要做的不是“说服模型回到 JSON”,而是在模型和程序之间建立一道强制转换的工序。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:提示词、代码节点、校验重试三层怎么搭
在动手改造前,我先盘了一下手头的工具。Dify 本身提供了三种重塑输出的手段,各有各的适用场景:
- 提示词约束:直接在系统提示词里规定输出格式,零成本,但效果不稳定,尤其多轮对话容易“忘形”。
- 工作流 Code 节点:把 SQLBot 的原始输出交给一个 Python 节点,通过代码清理、解析、归一化,强力兜底。
- API 层校验重试:如果目标是生产环境,最好再做一次 JSON 格式校验,失败时自动重跑一次或者走降级逻辑。
我最终的落地方案是三层组合:提示词负责“尽量输出对”,代码节点负责“错了也能修”,校验重试负责“万一还没修好就再试一次”。下面把每一层拆开讲清楚。
2.1 第一层:提示词约束,先定一个“说法”
这一层完全不写代码,只需要在 Dify 应用编排的“系统指令”里把规则说死。很多人写提示词喜欢用“请尽量输出 JSON”这种软约束,模型一听“尽量”,就真的只是“尽量”。正确的做法是给出一段明确到不能再明确的指令,最好把 JSON 结构直接贴给它。
我用的系统指令长这样:
code复制你是一名数据分析助手。接到用户问题后,你会把问题转换为 SQL 并执行。
你的输出必须是一个可被 json.loads 直接解析的 JSON 对象,不要输出任何解释文字、Markdown 代码块标记或其他内容。
输出结构固定如下:
{
"status": "success 或 error",
"message": "给用户的一句话说明",
"sql": "实际执行的 SQL 语句",
"data": [],
"total": 0
}
字段要求:
- status 只能取 success 或 error。
- data 是查询结果数组,查询失败时为空数组 []。
- total 是 data 的长度,必须为数字。
- sql 为实际执行的 SQL 语句,不要省略。
- message 里可以写自然语言,但整段输出必须是一个合法的 JSON 对象。
- 禁止在输出中出现“好的”“正在为您查询”“结果如下”之类的话。
这么写的好处是:把结构、字段含义、允许取值都一次性给模型列清楚了。实际测试下来,单轮查询的 JSON 合格率能有九成左右。但要注意,一旦用户连续追问,模型极容易被对话历史带偏,又开始输出自然语言。所以提示词是第一道保险,但不是最后一道。
2.2 第二层:Code 节点兜底,错了也能修
如果 SQLBot 是放在 Dify 工作流里的,那恭喜你,还有更稳的玩法。在工作流的 SQLBot 节点后面挂一个“代码”节点,把前面输出的原始文本塞进来,做一次“清洗 + 解析 + 回归”。简单说,就是不管模型输出什么东西,最后都过一遍我们的解析器。
我用的 Python 代码长这样:
python复制import json
import re
def main(raw_output: str) -> dict:
fallback = {"status": "error", "message": "empty", "sql": "", "data": [], "total": 0}
if not raw_output:
return fallback
text = raw_output.strip()
# 去掉常见的 markdown 代码围栏
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"\s*```$", "", text).strip()
# 只截取第一个 { 到最后一个 },防止开头/结尾混入解释文字
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
fallback["message"] = "no json object found"
return fallback
try:
obj = json.loads(text[start:end + 1])
except Exception as exc:
fallback["message"] = f"json decode error: {exc}"
return fallback
if not isinstance(obj, dict):
fallback["message"] = "root is not an object"
return fallback
data = obj.get("data", [])
if not isinstance(data, list):
data = []
return {
"status": obj.get("status", "error"),
"message": obj.get("message", ""),
"sql": obj.get("sql", ""),
"data": data,
"total": obj.get("total", len(data)),
}
这段代码里有几个细节值得说一下:
- 剥代码块:模型偶尔会自作主张地在输出外面套一个 ```json 的代码围栏。正则在解析前先把它剥掉,避免
json.loads报错。 - 只取首尾大括号:如果模型在 JSON 前面加了一句“已查询完成,结果为:”,那么整段文本是没法直接解析的。通过
text.find("{")和text.rfind("}")把 JSON 主体摘出来,是实战中非常有效的一招。前提是 JSON 内部的字符串里没有裸的大括号,实际概率很小,可以放心用。 - 统一兜底结构:不管解析成什么,最终都返回一个固定结构的字典。这样下游节点永远能拿到
status、data、total这些字段,不会出现某个字段缺失导致流程中断的情况。
2.3 第三层:API 出口校验与重试,保底中的保底
如果说前两层还是“内部消化”,那到了对外 API 这个场景,我建议再补一道校验逻辑。具体做法是:在 Dify 应用对外服务或者工作流结束前,对输出变量再做一次 JSON Schema 校验。如果校验失败,可以:
- 将原始输出记录到日志,方便排查;
- 自动重试一次查询,让 LLM 重新生成;
- 返回一个固定错误格式,避免下游接到一堆废数据还在那儿傻等。
你可能觉得这有点多此一举,但在生产环境里,一个“偶尔漏一次格式”的接口比“永远报错”的接口更可怕。前者会让下游系统间歇性失灵,而且不好定位。所以我的经验是:宁可让接口百分之百返回标准错误 JSON,也不要让它 95% 返回正常、5% 返回堆垃圾。
3. 实操一:把 SQLBot 调成“开口就是 JSON”
这部分我们从头到脚过一遍完整配置流程,方便你直接照着抄。
3.1 Dify 应用编排里的关键设置
在 Dify 里新建或编辑一个 Agent 应用,把模型选好之后,重点在“系统指令”和“模型设置”两个地方下手:
- 系统指令填上面那一段严格的 JSON 输出规则。
- 模型设置里把 Temperature 调低,我习惯调到 0.1 左右。温度越低,模型的随机性越小,输出越保守,也越不容易发散。如果平台支持 Top P,也可以设为 0.1 附近。
- 在“变量”里加一个
user_query输入变量,因为是 SQLBot,通常都要接收用户的自然语言问题。
然后,在调试预览里输入一个测试问题,比如:
code复制# 查询最近7天订单量前10的商品
看它能不能直接输出一段合法 JSON。第一次跑大概率能过,但别高兴太早,请再输入:“继续,查一下退款率最高的10个商品”,看第二轮的输出是否还是 JSON。这一步测试非常关键,因为多轮对话是格式跑偏的重灾区。
3.2 为什么我坚持把 SQL 也放进输出里
很多人只在输出里放 data 就完事了,但我强烈建议把 sql 字段一起放进去。有三个好处:
- 可审计:下游调用方如果对数据有疑问,可以直接看 SQL,知道这个结果是怎么查出来的。
- 可调试:当 JSON 的
data是空数组时,你很难判断是“查询条件没匹配上”还是“SQL 写错”,有 SQL 一眼就能定位。 - 可复现:出了问题,把 SQL 拿到数据库客户端里直接执行一遍,就能快速确认是不是模型生成的 SQL 有问题。
这个字段在调试阶段帮了我大忙,有一次模型生成的 SQL 里多了一个不存在的表别名,如果没把 SQL 带出来,光看结果完全猜不到原因。
3.3 模型能力差异带来的“隐藏变量”
不同模型遵循指令的能力差异非常大。实测下来,像 GPT-4 系列、Claude 系列、以及部分新版本的开源模型,对“严格输出 JSON”这类指令的执行力都还不错。但一些参数较小的模型,或者微调不到位的模型,哪怕提示词写得再细,它也可能在中间夹带一句“根据您的查询,我得到以下结果”。
遇到这种模型,我的建议是不要硬磕提示词,直接跳到第 4 章的“工作流 Code 节点”方案。跟模型讲道理,不如用代码堵住它的嘴。代码是确定性的,模型不是。
4. 实操二:工作流里挂一个 JSON 清洗节点
如果 SQLBot 是嵌在工作流中间,而不是直接对外提供 API,那改造方案会有更多选择。我通常把 SQLBot 节点命名为 query_sql,紧接着再加一个代码节点,命名为 format_json,一边做“脏数据清洗”,一边把解析后的结果往下游传递。
4.1 工作流节点的配置方式
在 Dify 工作流编辑器里:
- 在画布上点击“+”添加一个“代码”节点。
- 输入变量选
raw_output,类型为字符串,值引用 SQLBot 节点的输出文本。 - 把上面的 Python 代码粘贴进代码编辑器,注意 Dify 代码节点要写一个
main函数,入参名要与输入变量一致。 - 代码节点的输出变量就是我们清洗后的
result,类型为对象。
接下来,后面的节点直接引用 {{format_json.result.data}} 就能拿到干净的数组。比如接一个 HTTP 请求节点,把 JSON 作为 POST Body 发到下游系统,或者接一个“变量赋值”节点,把关键字段存成全局变量供后续使用。
4.2 为什么“截取首尾大括号”这一招很少被文档提起
大多数官方教程都会教你“正确解析 JSON”,但没告诉你模型输出天生就可能带噪音。我在网上看了不少帖子,都在强调提示词要写清楚,只有真正踩过坑的人才会意识到:解析器必须具备容错能力。
text.find("{") 和 text.rfind("}") 这两行,本质上是把一个不确定的、带噪音的文本,强行变成一个可解析的 JSON 片段。代价是如果 JSON 字符串内部本身有大括号(比如某个字段值里含了 {}),可能会截错位置。但在 SQLBot 场景下,查询结果一般不会出现这种极端情况,我可以接受这个风险。如果真遇到了,可以在数据入库前先做一次清洗,把值里的大括号统一转义,问题也不大。
4.3 大数据量场景的取舍
SQLBot 一查就是几百条数据的情况也很常见。如果直接在 data 数组里塞几百个对象,工作流节点之间的传输数据量会陡增,甚至拖慢整个流程。热词里常见“dify 工作流 上下文超长”,很多时候就是这么来的——SQL 结果被原样塞回 LLM 上下文,导致 token 爆炸。
我处理这类问题的经验有三个:
- SQL 层先做 LIMIT:让模型生成 SQL 时默认加上行数限制,比如
LIMIT 50。 - 数值字段做聚合:如果只是为了看趋势,先聚合再输出,不要抛明细。
- 超大 payload 走文件或对象存储:如果下游确实需要全量数据,就不要在 JSON 里裸传了,先把结果写到临时表或对象存储,JSON 里只放一个下载地址。
这个思路放在 Dify 里也成立:JSON 是传递结果的“信封”,不是装所有东西的“集装箱”。
5. 实操三:把字段映射固定成一份“契约”
提示词和解析器能保证“输出的是 JSON”,但还不能保证“JSON 的每个字段都有稳定含义”。这就要靠 schema 设计来解决。我最终的输出 schema 长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| status | string | success 或 error,程序快速判断 |
| message | string | 面向用户的一句话,也可以放错误信息 |
| sql | string | 实际执行的 SQL,方便审计与调试 |
| data | array | 查询结果数组,每项是一个对象 |
| total | number | data 数组长度 |
| generated_at | string | ISO 8601 格式的生成时间戳 |
你会发现,这个 schema 把“机器判断”和“人类阅读”两个需求都覆盖了。status 是给程序做流程分支用的;message 是给前端或机器人展示用的;sql 和 generated_at 属于元信息,主要服务排查;data 和 total 才是业务真正要消费的数据。
5.1 字段命名一旦定下来,就别再改了
我在项目里吃过一个亏:最初 schema 里用了 result 作为数据数组的字段名,后来又觉得 data 更通用,于是改了代码节点。结果忘了提示词里也写着一份旧 schema,导致模型偶尔还输出 result,下游解析就偶发失败。
从那以后我定了一条铁律:schema 只保留一份权威版本。如果用在提示词里,那代码节点也默认它;如果代码节点做了字段归一化,那提示词里的字段其实可以随意,只要代码节点能映射过来就行。
5.2 数据值本身的归一化比字段名更重要
字段名确定只是第一步,更坑的是数据值的格式。比如 MySQL 里的 Decimal 类型,json.dumps 处理起来会直接报错;datetime 类型需要转成字符串;bytes 类型更是不能直接序列化。很多人在 SQLBot 上线后突然收到“JSON 序列化失败”的报警,原因多半就在这一层。
我在代码节点里专门写了一个数值归一化函数:
python复制def normalize_value(value):
import decimal
from datetime import datetime, date
if isinstance(value, decimal.Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore")
return value
然后在组装 data 数组时逐个字段调用它。别小看这个函数,它解决了我手上大部分奇怪的序列化报错。JSON 本身只支持对象、数组、字符串、数字、布尔和 null,凡是数据库里的特殊类型,一律先转成这几种基础类型再说。
5.3 空值策略:要么约定,要么在后端统一
SQLBot 查询经常遇到空结果。比如“查询昨天没有订单的城市”,数据库返回空集合,那 data 到底是 [] 还是 null?不同模型可能给出不同的答案,下游代码如果没做兼容,就会出现 len(None) 直接崩掉的情况。
我的处理方式是在提示词里明文规定“data 当查询失败或无结果时统一为空数组 []”,同时在代码节点里也强制兜底:解析结果里 data 如果不是 list,一律置为 []。这样下游永远不会收到 null 的 data,可以省掉一打堆防御代码。
6. 典型故障:从格式跑偏到凭据报错的排查实录
再稳定的方案也架不住环境变化。我把自己在 Dify 里做 SQLBot 时踩过的坑整理成了一份速查表,遇到同类问题可以直接对照着查。
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 返回纯文本或解释 | 提示词约束不够强,或模型多轮后“失忆” | 在提示词里贴完整 schema,并压低模型温度 |
| 返回被 ```json 包裹 | 模型根深蒂固的 Markdown 习惯 | 解析前剥离代码围栏 |
data 字段偶发缺失 |
模型临时修改了输出键名 | 代码节点做字段归一化,缺省置空 |
中文变成 \uXXXX |
JSON 序列化默认 ASCII 转义 | 对解析方无实质影响,可用 ensure_ascii=False 增强可读性 |
| 查询结果太大,工作流变卡 | SQL 没有 LIMIT,数据量超限 | 在 SQL 层限制行数,或做聚合 |
| 工作流提示“上下文超长” | 大段 SQL 结果被塞回 LLM 上下文 | 控制查询结果行数,避免全量回传 |
| Dify 报 credentials validation 错误 | 模型供应商 API Key 或 Endpoint 配置不正确 | 核对模型类型、API Key、Endpoint 地址 |
| 调用模型报 SSL 相关错误 | 本地模型服务的证书或 base URL 配置异常 | 检查服务证书是否被系统信任、base URL 是否填写正确 |
| 知识库文件一直“排队中” | 文档索引任务还没跑完,或队列拥堵 | 等待索引完成,或检查分段策略后再重新触发 |
表格看着简单,排查过程其实是最耗时间的部分。这里分享一个我的排查套路:先把原始输出落下来,再做格式判断,最后才去改代码。 具体做法是在工作流里加一个临时调试节点,把 SQLBot 的原始输出打印到日志,或者写到一个表里。很多人一看到格式不对就直接改提示词,其实先看一眼原始输出,能省掉一半的盲目试错。
还有一个我常用的技巧:JSON 解析失败时,不要只报错,要把错误位置一起报出来。Python 的 json.JSONDecodeError 会带 lineno 和 colno,把这些信息带进 message 字段,排查效率会高很多。比如 char 18: expecting '}',一看就知道是某个字段后面少了括号,十有八九是模型把字符串写走了样。
7. 最终流程长什么样:一个可以直接抄的架构
把我上面说的所有内容拼起来,最终跑通的架构是这样一条链路:
- 用户或下游系统发起查询请求,传入自然语言问题。
- Dify SQLBot 节点接收问题,生成 SQL,执行查询,返回原始输出。
- 紧接着的“代码清洗”节点把原始文本解析成标准 JSON。
- 代码节点内做字段归一化、空值兜底、类型转换。
- 标准 JSON 作为工作流下一步的输入变量,或作为 API 响应返回给调用方。
- 如果是生成环境,外加一道 schema 校验,失败则重试一次,并记录日志。
整个过程下来,我最大的感受是:让 LLM 输出 JSON,不是靠提示词“求”出来的,而是靠代码“逼”出来的。提示词给模型指了一条路,但路上会有各种意外,真正确保它走到终点的,永远是最外层的确定性代码。
另外,Dify 自身迭代得也很快,社区版功能一直在加。你要是长期做这类应用,建议把工作流节点的日志机制、变量管理、版本发布这些能力用起来,别只在调试界面里点来点去。把 SOP 固化在工程结构里,下一次做类似的 SQLBot 或知识库问答应用,就能直接复用这套“输出转 JSON”的模式,省得再从零开始踩坑。
