如果你以为“自然语言生成 Workflow JSON”这件事,核心难点是让模型“听懂人话”,那只能说对了一半。我做完这个工具之后最深的体会是:自然语言转 JSON 本身并不难,难的是让生成的 JSON 真的能被 workflow 引擎认下来、跑起来、并且在跑挂的时候知道该怪谁。这篇文章就把我完整的实现思路、踩坑过程、修复链路和一些验证数据整理出来,全程没有遮遮掩掩,希望能帮到正在做同类工具的人。
1. 做之前先看清:直接“让模型自由发挥生成 JSON”这条路为什么走不通
先说结论:大模型直接生成 JSON,单看字符串几乎都是合法的,但放到 workflow 场景里,大部分都经不起推敲。你要的不是一段能通过 json.loads 的文本,而是一份能和目标执行器严格对齐的数据结构。
1.1 表面问题是格式漂移,深层问题是“语义和 schema 没有绑死”
我一开始也走了所有 LLM 应用都会走的捷径——在 prompt 里写一句“请只输出 JSON,不要输出任何解释”,然后期望模型稳定返回干净 JSON。实际测试跑了二十条,结果让我很清醒:
- 有的返回体被 markdown 代码块包住,虽然不是大事,但每次都要做一层剥离。
- 有的把
dependencies写成了depends、depends_on、dependency,每次都不一样。 - 有的把数字写成了字符串,比如
retry: "3"。 - 最头疼的是字段缺失。我想让一个任务在失败后自动重试 3 次,模型生成的 JSON 里只写了任务名和脚本,完全没有
retryPolicy这一段。字段缺失在解析阶段不会报错,因为上层结构还是合法的 JSON 对象,直到真正执行时才暴露问题。
只要 prompt 被模型“自由意译”一次,字段名、层级、枚举值都可能被丢掉。这时候模型用的不是通用 JSON,它只是画了一张长得像 JSON 的结构图。所以做这个工具之前,必须先建立一个认知:模型是“建议器”,不是“决定器”,生成的输出必须被强 schema 约束,才能叫 Workflow JSON,否则只能叫“长得像 JSON 的 Markdown”。
1.2 你要做的不是“JSON 格式化工具”,而是“语义到 schema 的编译器”
那为什么不能只用 prompt 调一次就完事?因为 prompt 负责的是“从自然语言中抽出意图”,schema 负责的是“把意图落进确定的执行模型”。这两件事最好分开做。
把问题往编译器方向想就顺了:前端是自然语言,中间是一个受限的 IR,后端是目标 workflow 的 artifact。全文的核心设计实际上是一条“前端不信任模型、后端强校验输出”的管线。
text复制自然语言描述
↓
LLM 解析成语义中间层(结构化意图)
↓
使用目标 schema 校验和补全
↓
修复 + 规范化
↓
输出可执行的 Workflow JSON
这一步决定了后面整个工具的实现方式。接下来先界定我们到底在给哪种 Workflow 生成 JSON。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具边界:你生成的“Workflow”到底是哪一种 Workflow
很多文章教你“生成通用 JSON 工作流”,但现实中并没有一个通用的 workflow 执行器。Argo Workflows、Airflow、Temporal、n8n 里的“workflow JSON”完全是不同的物种。如果目标格式都没定,后面实现做得再漂亮,也只是做了一款只能看不能跑的玩具。
2.1 我选择的最小可用目标:DAG 模式 + 依赖关系 + 可执行步骤
我这个工具的目标格式定义得比较保守,没有企图覆盖所有平台,而是把重点放在“表达依赖关系的任务流程”上,核心结构长这样:
json复制{
"name": "data-pipeline",
"description": "数据拉取、清洗与模型训练",
"steps": [
{
"id": "fetch_data",
"action": "http.call",
"params": {
"url": "https://api.example.com/data",
"method": "GET"
}
},
{
"id": "clean_data",
"action": "script.python",
"params": {
"script": "clean.py",
"env": {"INPUT": "fetch_data.output"}
}
}
],
"dependencies": [
{"stepId": "clean_data", "dependsOn": "fetch_data"}
]
}
实际工程中还会有 retry、timeout、errorHandler 等字段,但骨架就是这个。这样一个最小格式能讲清楚绝大部分技术问题,切换到 Argo 或 Airflow 的 spec 时,差异主要在字段名和嵌套层级,原理是一样的。
2.2 明确支持哪些自然语言要素
这一步很多人会忽略,但非常重要。工具不能什么描述都硬接,必须像产品需求一样设定输入范围。我最终把支持的 NL 意图收敛成四类:
- 顺序依赖:“先拉数据,再清洗,最后入库”。
- 并行分支:“拉取数据的同时,并行跑模型训练”。
- 失败处理:“如果拉数据失败,重试三次”或“失败后发通知”。
- 定时触发:“每天早上八点执行”。
为什么要收敛?因为工作流的自然语言描述里最怕遇到“我不懂,但我想让你把这句话也变成工作流”的情况。比如用户说“把数据库搞得更快一点”,这种偏向意图的不是工作流描述。工具必须在入口处用分类器或规则快速判断,能抽出来就抽,抽不出来就反馈缺少的参数,而不是强行编。
2.3 还需要定义“输出不可执行”时的行为
做这个工具我还定了一个原则:宁可返回一个错误说明,也不要返回一段看似成功实际无法解析的 JSON。很多工具为了演示效果好,只要模型生成了合法 JSON 就算成功,就把结果返回给用户。实际上一执行就报 “missing field” 或 “failed to deserialize” 的错误,这比模型直接说“我不会”更伤人。
所以这个工具的出口处永远有一道校验,只有完整过了 schema 校验的数据才叫成功。这一命名规则也让我在调试过程中少吵了无数次架:schema 执行之前我拿数据集一跑,立刻就知道到底是个别修复还是普遍问题。
3. Step by Step:把自然语言翻译成可执行 JSON 的完整实现
这部分的实现思路,我尽量按实际编码顺序讲清楚。整套代码用 Python 写的,核心只有几百行,加上测试和规则库不到两千行。依赖的组件是 Pydantic、JSON Schema 校验库,以及一个 LLM 客户端。你可以针对自己的平台替换。
3.1 用“语义中间表示”稳定模型输出格式
第一步不是让模型直接输出最终 Workflow JSON,而是先让它输出一个“意图中间表示”。我定义了一套极简的中间数据模型:
python复制from pydantic import BaseModel, Field
from typing import List, Optional
class IntentStep(BaseModel):
id: str
verb: str = Field(description="动作类型: fetch / process / notify / train / store")
target: Optional[str] = None
description: str
class IntentDependency(BaseModel):
source: str
target: String
class WorkflowIntent(BaseModel):
name: str
steps: List[IntentStep]
dependencies: List[IntentDependency]
error_policy: Optional[String] = None
trigger: Optional[String] = None
中间表示的好处是容错率高:字段少、不绑定任何引擎、训练数据容易构造、模型回答失败时容易判断该补哪个字段。后续再从这个中间表示映射到目标平台格式,就是一个确定性的“翻译器”,不需要模型参与。
3.2 提示词里必须放什么:不只有任务描述和输出要求
Prompt 我根据实际经验迭代了很多版,最终核心逻辑只有这几块,但每一块都不能少:
- 角色与目的限制:不是对话助手,而是“工作流解析器”,只返回工作流解析结果。
- 目标 schema 的明确定义:我甚至会直接把 JSON Schema 里的字段名、required 项拷进 prompt,告诉模型这些字段必须逐个对应。
- 正反示例:示例放两个,一个常规顺序任务,一个并行加失败处理的反例。
- 输入质量声明:当输入描述明显不足以生成完整可执行流程时,指令模型返回
{"status": "need_more_info", "missing": ["触发方式", "失败处理"]},而不是强行生成一个默认值。
我用的是“少了一点补,多了就裁”的策略:模型先按中间表示输出,然后我在它背后加一个强制字段检查。只要少了一个必填项,并且我们能通过上下文推理补出来,就自动补;补不出来的,走 need_more_info 分支。
3.3 后端修复链路:先解析、再校验、再修复、再校验
把模型输出拉回来之后,我并不直接把文本丢给 Pydantic,而是先做一次“粗清洗”。因为实际收到的输出里总有一些奇怪的东西:
json复制{
"name": "daily-etl",
"description": "每天拉取订单数据并清洗",
"steps": [
{"id": "pull", "verb": "fetch", "target": "api.order"},
{"id": "clean", "verb": "process"}
],
"dependencies": [{"source": "pull", "target": "clean"}]
}
如果模型输出里多了一个空字段 {"description": ""},或者 steps 数组用了双引号套字符串数组,这些都要在这一层处理掉。粗清洗阶段我做了四件事:
- 剥离 markdown 代码块标识。
- 找到 JSON 的起始和结束大括号,把中间部分直接截出来,抵抗模型输出前后废话。
- 用
json.loads尝试解析,失败则进入轻量修复规则。 - 解析成功后和
WorkflowIntent模型做字段对齐,检查 required 和空列表。
python复制def normalize_and_parse(raw_text: str):
cleaned = strip_code_fence(raw_text)
start = cleaned.find("{")
end = cleaned.rfind("}")
if start == -1 or end == -1:
raise WorkflowParseError("没有找到有效 JSON 对象")
json_text = cleaned[start:end+1]
data = json.loads(json_text)
return WorkflowIntent(**data)
这里的 WorkflowIntent(**data) 看着简单,但 Pydantic 在后面做了很多事。比如枚举校验 verb 必须属于可执行动作集合,dependencies 里的 source/target 必须存在于 steps id 列表,这些规则直接挡掉了大量“看起来能跑,其实一跑就崩”的脏数据。
3.4 从中间表示到目标 JSON 的映射器
解析成 WorkflowIntent 后,剩下的动作都是确定性的。我写了一个 mapper,把 Intent 对象转成前面定义的目标格式,中间处理了依赖聚合、错误策略分配、Action 名称映射等:
python复制class WorkflowMapper:
def map(self, intent: WorkflowIntent) -> dict:
steps = [
self._map_step(step)
for step in intent.steps
]
deps = [
{"stepId": dep.target, "dependsOn": dep.source}
for dep in intent.dependencies
]
return {
"name": intent.name,
"description": intent.description,
"steps": steps,
"dependencies": deps,
"errorPolicy": intent.error_policy or "terminate"
}
这个 mapper 只做机械变换,防止模型直接对最终格式“自由创作”。这样即使模型在中间表示阶段产生了轻微的字段摆放错乱,最终还是能按规范输出。
4. 实测阶段最容易翻车的四个细节
光说实现可能显得太顺,我把自己跑了上百条测试之后遇到的高频翻车点列出来,每条都对应真实报错和修复方案。如果你在做同类型工具,这部分建议直接保存。
4.1 模型喜欢脑补不存在的输入字段
第一次跑时我给的提示词是:“从订单接口拉取数据,写入本地数据库。”模型生成的 JSON 里直接写死了:
json复制{"url": "https://api.orders.example.com/v1/list"}
看着没问题对吧?问题是用户根本没给地址,这个 URL 完全是模型“猜”的。这种猜出来的值放在测试环境里看着合理,一旦上线就会把请求打到错误地址。
解决方法是把字段来源分成两类:一类是自然语言中显式给出的,直接填入;另一类是没有给出的,生成占位符 <INPUT_REQUIRED: url> 并交给校验层拦截。宁可让人补一个字段,也不能让机器背着一个幻觉地址去生产环境。
4.2 JSON 数字精度和类型问题
热搜词里有“json中number超范围了,怎么处理”,这个我也碰到过。模型在表示超时时间或并发数时,很容易把大整数写成浮点数,比如把重试间隔 1.5 写成 1.5000000001,或者把一个 timestamp 字段写成字符串。Pydantic 的严格模式在这里起了很大作用。
我的做法是:在目标 Schema 里对数值字段全部启用约束,比如最小值、最大值、整数 vs 浮点、unit 等。如果发现是极小误差的浮点则做取整,如果是单位错误就拒绝而不是擅自换算。因为 workflow 里的 timeout 是以秒为单位的,你不能因为模型写个 300s 就自动认为是合法数字。
4.3 顺序依赖与并行分支的图结构表达
另一个典型问题是依赖关系被描述成线性列表而不是 DAG。比如自然语言说:“先拉数据,数据拉完同时跑模型训练和报表生成,两边都结束了再发消息。”模型经常输出一串顺序列表:
json复制["fetch_data", "train_model", "generate_report", "send_notify"]
这错得非常隐蔽:从列表来看步骤都存在,但并行关系完全丢了。正确结果应该长这样:
json复制{
"dependencies": [
{"stepId": "train_model", "dependsOn": "fetch_data"},
{"stepId": "generate_report", "dependsOn": "fetch_data"},
{"stepId": "send_notify", "dependsOn": "train_model"},
{"stepId": "send_notify", "dependsOn": "generate_report"}
]
}
这个坑我建议不要试图用 prompt 完全解决,因为模型并不擅长对长句做图结构推理。更好的方式是和用户交互确认一次:“我有两个步骤 A 和 B,它们在 fetch 完成后是否需要互相等待?”我用这个方式把并行解析准确率拉到了可接受范围。
4.4 错误处理策略是模型记忆最薄弱的地方
你有没发现,模型对主流程的概括能力很好,但只要你不在 prompt 里点明“失败情况下要做什么”,它生成的 JSON 里就永远没有 error handling 块。为了应对这个缺陷,我在中间表示里增加了一个默认值逻辑:
- 自然语言里明确提到“失败”“重试”“异常”“补偿”相关词,才生成
error_policy。 - 没提,就用默认的
terminate,而不是让模型选择。
为什么要这么死板?因为 workflow 的错误策略是有业务语义的:失败就终止和失败就重试,在线上是两个完全不同的行为。系统不该替用户做这种决策,默认保守策略更安全。
5. 校验和修复链路:真正把准确率打上去的,是后端规则而不是模型重试
很多人遇到模型生成 JSON 不合法,第一反应是“多跑几次,不行就换个 prompt”。我这么做之后发现效果有限,因为同一模型同一 prompt 在边界情况下的失败很稳定。后来我把重心移到“修复链路”上,准确率才真正上来。
5.1 不要每轮都让模型“重新生成整个 JSON”
第一版我也设计成了常见的 self-correct 循环:校验失败,把错误信息拼进 prompt,让模型再生成一次。实测结果很不稳定:第一轮格式错,第二轮能修复格式错,但很容易把原本正确的字段内容改错,甚至越改越远,出现把整个 JSON 结构丢掉的情况。
后来我改成只修复局部错误:
- 如果是 JSON 语法类错误,比如缺引号、多逗号、截断,用解析规则或轻量解析容错。
- 如果是 schema 校验失败,把具体缺失字段反馈给模型,并且明确告诉模型“只输出缺失字段,不要重复完整 JSON”。
- 如果是类型错误,例如该整数给成字符串,优先用代码转换,而不是让模型猜应该填什么。
这样把修复职责从“模型重写”变成“规则修复为主、模型补充为辅”,整体稳定性高了很多。
提示:修复链路本身也需要有上限。如果连续两次修复仍然失败,直接返回校验失败详情给调用方,别让它无限修复。因为你最终还是要让用户知道输入哪里不够,而不是一直假装自己可以读懂一切。
5.2 基于 Schema 的模板骨架回填
当模型漏掉一段结构时,最可靠的修复方式是“拿 schema 当尺子量,缺哪里就补哪里”。我用 JSON Schema 库生成一个对象模板,这个模板里包含所有 required 字段的默认值、类型结构和占位符。模型提供不了的值会被标记为需要用户输入。注意这里生成的不是真实的执行参数,只是骨架,防止后续字段缺失错误变成执行期的幽灵。
5.3 Prompt 对比最终结果
最后我用同一批 100 条测试样本分别跑三版实现,得到的对比结果很直接:
| 版本 | 直接输出通过率 | 修复后最终通过率 | 备注 |
|---|---|---|---|
| 纯 Prompt 一段式 | 37% | 41% | 模型自由发挥严重,字段缺失多 |
| 引入意图中间表示 | 58% | 63% | 格式稳定性明显提升 |
| 中间表示 + 规则修复链路 | 80% | 91% | 主要剩余问题集中在语义歧义 |
这个结果说明:模型生成的字符串质量只是瓶颈之一,更大的提升空间在解析、校验和规则修复这一层。如果你的工具到了“模型输出看起来还行,但下游执行总是报错”的瓶颈期,回头检查一下后端修复链路,大概率能找到突破口。
5.4 关于“自然语言生成 js 脚本”和“MCP”的一个选择
在新一轮探索中,我也验证了热词里“自然语言生成 js 脚本,需要自己实现 MCP 还是用现有的 MCP”这个问题。从我有限的使用经验来看,重点工作如果只是“把自然语言约束成 JSON Schema”,没有太多必要自己实现一套 MCP。只要把 schema 作为 tool 的描述暴露给已有的 MCP 服务,让模型自动按工具描述去调用,就能达到不差的收束效果。真正需要原生 MCP 的场景是“模型需要实时读取远端 schema 或者动态感知 workflow 模板”,那才值得自己搭一套。一开始不要给自己加戏,先接现有的。
6. 做完整套工具后的思考:这份经验能复用到什么场景
这个工具的核心产出一个 Workflow JSON,但提炼出来其实是三件事:把用户语义转换成结构化意图,把结构化意图映射成目标 schema,用确定性的校验规则保证产出可控。任何“将自然语言转成配置文件”的工具都可以参考这一套,包括 Kubernetes YAML、Terraform 配置、CI 模板、知识库的 JSON 书目系统等等,原理完全一致。
6.1 这套架构最值得复用的不是代码,而是“确定性与不确定性的分层”
全系统设计里,只有“从自然语言到意图中间表示”这层是非确定性的,其余全部是确定性逻辑。这样分层让问题定位很简单:当输出不对,要么是模型理解错了,要么是规则写错了,不可能两者搅在一起。你在 debug 时只需要看校验日志里的“哪个字段失配”,就能判断是哪一层的锅。
6.2 未来想扩展的方向
如果继续做,我会把注意力放在两件事上。
第一是支持用户自定义 schema。现在工具内置了固定 workflow 格式,但真正的用户可能已经有一份复杂的 Argo Workflow 或内部引擎 schema。只要让用户上传 JSON Schema,后续的解析、校验、模板生成会复用同一套逻辑,工具价值能上一个台阶。
第二是支持交互式澄清。现在很多失败是因为用户描述太短,只给少量信息。工具只能报“缺少字段”。但如果能做成多轮对话:“你说‘拉取数据’,这一步请求的 URL 是什么?”“如果失败需要重试吗?重试几次?”这样生成的成功率会提高很多。基于意图中间表示,其实已经天然支持多轮补齐,只是需要再接一层对话管理,工作量在可控范围。
6.3 给同样在做自然语言生成工具的人几个不算成熟的建议
如果你也要做类似的项目,个人体会最深的几条是:
- prompt 里写满 20 条规则,不如在代码里做 3 个字段校验。模型输出的稳定性上限是概率性的,规则系统才是确定性的。
- 不要试图让模型“全知全能”。一个 workflow 配置里缺少业务参数时,直接问用户比猜一个更可靠。大多数执行期事故都源自构建期的“猜测型默认值”。
- 一定要在开发时就埋好一条完整的“原始输出日志”链路。每次请求都把模型返回的原始文本和修复前后的 diff 存下来,这样你定位问题会快很多。不存日志直接改 prompt 的做法,会让你在第五次失败后彻底迷失。
- 如果你的目标是 Argo Workflow 这类复杂引擎,别直接从自然语言一步生成最终的 YAML/JSON。中间加层薄薄的建模表达,后面接自定义模板渲染,维护成本低很多。
我最终也没有把这个工具做成一个特别大而全的产品,但它确实让我从混乱的 prompt 调试里解放了出来。大多数“自然语言生成结构”的项目,做到最后你会发现自己并不是在解决“理解自然语言”的问题,而是在解决“如何让无序的东西落进有序的结构”的问题,后者靠工程规范能解决大部分隐患。希望这篇实现思路能让你少走几段弯路。
