引子
前段时间做了一个把自然语言转成架构图的小工具 ------ 一句话描述流程,LLM 生成 Mermaid 代码,右边实时预览。用了几个月,反复收敛到两类失败:
一类是 LLM 生成的 Mermaid 看起来对,mermaid.js 一渲染报 Parse error,用户看到红色堆栈就走了。这个不是调 prompt 能治的,模型偶尔就是会写错,能做的是让它错完还能自愈。
另一类更隐蔽。用户说"画个登录流程",LLM 顺手补一个"发送验证码"进去,不问不留档。图出来之后用户想改,但没有锚点 ------ 中间那份需求根本没显性化。
这两个问题指向同一件事:单次调用不够。得在"提取需求"、"生成代码"、"校验"之间加一层结构,任何一步崩了都能兜住。第二版就是把它改成一个 Agent ------ 多轮追问、语法自愈、能记住用户明说过"不要"的东西。这篇讲这套架构长什么样。
支持八种图:Sequence · Flowchart · ERD · State · Class · Mindmap · Gantt · Architecture。两种模式:auto (LLM 自己猜要画哪种)+ manual(用户明说)。
一、这不是一个 Loop,是两个
多轮生成 UML 看起来是一个 Loop,拆开是两个,共享同一份状态,但目标完全不同。
vbnet
外层 · 需求澄清 Loop
用户自然语言 → 提取事实 → 发现缺失 → 追问 → 更新需求
↓
需求达到可绘制状态
内层 · UML 生成修复 Loop
结构化 spec → 生成 UML → 语法 / 语义校验 → 修复 → 输出
| 维度 | 外层澄清 | 内层生成修复 |
|---|---|---|
| 输入 | 自然语言 | 结构化 spec |
| 输出 | 结构化 spec | Mermaid 代码 |
| 成功条件 | schema 关键字段填齐 | Parser 通过 + 语义 Validator 通过 |
| 决策者 | Planner(问不问) | Validator(修不修) |
| 失败兜底 | 预算耗尽 → 强行 proceed + 记录 assumption | 修复 3 次上限 → 降级呈现失败图 |
混在一起做的话,你会遇到"这个歧义要不要问用户"这个死循环 ------ 因为外层歧义(用户没说)和内层歧义(LLM 生成偏了)看起来都是"缺信息",实际处理路径完全不同。前者走追问,消耗预算;后者走内部修复,不打扰用户。
核心洞察:两层 Loop 的边界不是"外层管对话、内层管代码",而是"外层管信息够不够、内层管代码对不对"。拆开后每层有明确的成功条件、决策者、失败兜底,后面所有决策才有落脚处。
二、Agent 内部六角色分工
内部再拆,把 Agent 想象成一个团队。这个比喻是我落地过程中试出来最好用的一版,后面很多设计围绕它展开。
| 角色 | 做什么 | 明确不做什么 |
|---|---|---|
| Extractor | 从对话历史提取事实、填 spec | 不猜、不推断,留 unknown |
| Evaluator | 遍历 spec,识别缺失与歧义 | 不判断问不问 |
| Planner | 拿(缺失清单 + 预算 + ask_log)决定下一步 | 不自己去问用户 |
| Generator | 生成 Mermaid,可以补合理 assumption | 但每个 assumption 必须留档 render_context |
| Validator | 语法校验 + 擅自检测(spec-assumption 对账) | 有错就退回 Generator 重试 |
| Orchestrator | 按顺序调度上述五个,维护 AgentState | 不做任何判断 |
一开始我把这些揉在一个巨型 prompt 里,让 LLM 一次调用同时做提取、判断、生成。跑了两周开始 debug 就崩溃了 ------ 一段错误的输出说不清是 Extractor 记错、Evaluator 漏、还是 Generator 瞎猜。角色拆开之后每一步都是纯函数,输入定了输出就定了,能单独 mock、单独测。
Functional Core / Imperative Shell 是这个分工的正式说法 ------ Orchestrator 是唯一 stateful 组件,其他五个都是纯函数。
2.1 调用关系:严格顺序的流水线
六个角色不是并联,是一条严格顺序的流水线,Orchestrator 是唯一调度者。
scss
用户消息
↓
Orchestrator (append 到对话历史,维护 AgentState)
↓
Extractor ────→ 从对话历史提取事实,填 spec
↓
Evaluator ────→ 遍历 spec,输出 MissingSlot 清单
↓
Planner ────→ (清单 + budget + ask_log) → Decision
│
├─ AskClarification ──→ QuestionStrategy ──→ User
│ ↑ (下一轮从最上面开始)
│
└─ Proceed ──→ Generator ──→ 生成 Mermaid
↓
Validator · 语法校验
│
├─ FAIL → 内层修复循环(退回 Generator,上限 3)
│
└─ OK → Validator · 语义校验(spec-assumption 对账)
↓
擅自添加?
│
├─ 是 → 图 + Confrontation 一起呈现
│
└─ 否 → 直接呈现
↓
User
- Orchestrator 是唯一 stateful 组件。别的角色都是"输入 → 输出"的纯函数,不记得上一次。多轮对话的所有记忆全在 Orchestrator 维护的 AgentState 里 ------ conversation_history、spec、render_context、ask_log、budget。
- Planner 是唯一分叉点。整个 Agent"问 or 画"就它一个人决定,想调追问节奏改这一个函数。
- 内层修复循环发生在 Generator 和 Validator 之间,不惊动用户。Mermaid 语法自愈就靠这一段。
- 语义校验不阻塞出图。发现擅自添加时,图和追问一起呈现 ------ 用户看着图判断比看着抽象需求容易得多。
2.2 关键规矩:Extractor 保守,Generator 可以猜
这是整个架构的地基。
用户说:"画个登录流程,用户输密码,系统验证后返回结果。"
Extractor 输出:
css
{
actors: ["用户", "系统"],
events: [
{ from: "用户", to: "系统", action: "输入密码" },
{ from: "系统", to: "用户", action: "返回结果" },
],
sync: "unknown" // ← 用户没说同步/异步,绝对不能猜
}
sync: "unknown" 是追问的信号 ------ Evaluator 看到 unknown 标"G2 歧义",Planner 拿到就决定要不要问。
如果 Extractor"贴心"补了 sync: "sync"(理由:登录一般是同步),这个信号就消失了。Evaluator 看到具体值不会标歧义,Planner 就不会问。用户从头到尾没被问过同步异步,但图画的是同步。这是把 Agent 的判断力偷偷让渡给了 LLM 的直觉,还没有留痕。
Generator 没有这个约束。它可以补一个"验证码服务"进 Mermaid,但每个 assumption 必须写进 render_context.assumptions。Validator 后面拿 spec + assumptions 对账 ------ 图里出现的每个元素必须能追溯到"用户明说"或"Generator 的显式假设"。追溯不到的,叫擅自添加(unauthorized addition),Validator 会拉出来做 Confrontation。
一个 case:用户后来说"响应不要,只画到存数据库就行"。这句话触发的东西比较多 ------ 撤销响应箭头、重新生成。但重新生成时有个坑,LLM 惯性又给你补上响应箭头。不能靠 prompt 治(prompt 治得住第一次治不住 N 次),得靠数据结构。所以 spec 除了正事实还有一栏叫负事实:
bash
type SequenceSpec = {
actors: Actor[]
events: Event[]
sync: "sync" | "async" | "unknown"
// 负事实(用户明确说"没有"的东西)
actors_negative: Actor[]
events_negative: EventPattern[]
}
Generator 的 prompt 每轮包含负事实 hint("用户明确表示不要以下元素:响应流程"),生成时主动规避。就算 Generator 忘了,Validator 兜住 ------ 负事实里出现的元素被再次擅自添加,直接判 CRITICAL。
核心洞察 :
unknown是一等公民。它区分了"用户真说过 sync" vs "我瞎猜的 sync"。这一步妥协了,后面 ask_log、budget、Confrontation 全部瓦解 ------ 因为 Agent 分不清自己是在追问用户没答过的问题、还是在追问自己胡编的字段。
三、Planner 独占预算决策
第二条架构决策:所有"问不问用户"的判断集中在一个纯函数里,叫 Planner。
css
type PlannerDecision =
| { kind: "AskClarification"; slot: MissingSlot }
| { kind: "Proceed" }
| { kind: "RequestConfirm" }
function plan(
candidatePool: MissingSlot[],
budget: number,
askLog: PastQuestion[],
): PlannerDecision
输入契约:
- Evaluator 和 Validator 只识别问题,不判断"该问不该问"。
- Planner 是 Agent 里唯一的追问决策入口。
- Planner 无状态,
ask_log由 Orchestrator 传入 ------ 让它能区分"未问过的 unknown" vs "问过没答的 unknown",避免重复追问。
一个 session 有个全局预算 (实测取 5)。只有 Agent 主动打扰用户才消耗预算 ------ 用户主动改需求、修图、要求重画、对 Confrontation 的回答,全部不扣预算。
区分之前我用的是"每轮对话消耗一个预算"。跑了一段时间发现用户越用越不敢说话 ------ 每次开口都可能触发追问,慢慢用户学会直接说"先画看看"跳过所有澄清。这不是我想要的,澄清是价值最高的一步,用户躲开澄清 = Agent 白干。改成"只有主动追问扣预算"之后用户敢说话了。
工程收益三条:
- 可回放:序列化 AgentState 就能复现任意一轮决策。收到 issue 时能完整重放,不用猜"那次 conversation 长啥样"。
- 可测:每个纯函数拿假的输入就能测边界 ------ budget=1 / ask_log 有 2 次回避 / spec 全空。
- 可 fork:同一个 state 用两个不同 Planner 策略跑,直接 A/B 对比。
核心洞察:policy 集中在一个纯函数里,想调追问节奏、加分段预算(前 2 轮严格 / 后 3 轮松)、加"剩余预算 ≤ 1 只问最重要的",全部只改这一个函数,别的组件不动。
四、Two-Pipeline:预算之内的追问 vs 预算之外的核对
早期版本我把所有追问和核对都塞进 Planner,发现两类东西性质根本不一样:
- 画之前发现缺关键信息 ------ 得阻塞用户,先问再画,得花预算。
- 画之后发现擅自添加 ------ 图已经出了,让用户看着图核对更直观,不该花预算。
硬塞进一个 pipeline 会出问题 ------ 预算被后一类蚕食,前一类就没机会问关键的东西了。所以架构裂成两条 pipeline:
sql
Pipeline A · pre-render · budget-gated · Planner 决策
Evaluator MissingSlot → Planner → AskClarification → User
(budget - 1)
Pipeline B · post-render · budget-free · Orchestrator 自动附加
低置信 MetaExtractor 推断 ┐
Validator HIGH/CRITICAL 擅自添加 ├→ pending_confrontations
Generator 生成期兜底 assumption ┘ ↓
Renderer 出图时一起呈现
↓
User 确认
↓
升级到 spec / 撤销 + regenerate
| 维度 | Pipeline A | Pipeline B |
|---|---|---|
| 触发时机 | 生成前 | 生成后(附着于图) |
| 决策者 | Planner | Orchestrator(自动附加) |
| 消耗预算 | 1 | 0 |
| 阻塞流程 | 是 | 否(可跳过) |
| Question kind | Clarification | Confrontation |
| 用户答"是"效果 | 填 spec | 升级 assumption 到 spec |
| 用户答"否"效果 | 记 ask_log(回避) | 撤销 + regenerate + 写入负事实 |
核心洞察:Budget 语义精确化到"用户在看到图之前被 Agent 主动打扰的次数"。图出来之后的所有核对都不算,用户就不会有"越用越怕说话"的心理压力。
五、加一种新图类型的成本
新图 = 新增 4 个 sealed permit 分支:
go
type UmlSpecification =
| SequenceSpec
| ClassSpec
| FlowchartSpec
| StateSpec
| ERDSpec
| MindmapSpec
| GanttSpec
| ArchSpec // ← 新加一种,只加这一行 + 4 个 permit
每种图独立实现四个组件:
- Extractor ------ 如何从自然语言提取事实
- Evaluator ------ 哪些字段是 G1 阻塞 / G2 歧义 / G3 精细
- Validator ------ 擅自检测规则 + Severity 判定
- QuestionStrategy ------ 措辞(时序图问"参与者",类图问"实体")
不动的组件:Planner、Orchestrator、AgentState、共享 enum、MetaExtractor(auto/manual 分类)、Rescope 逻辑。图类型的差异被"上下夹"的两层吸收掉了 ------ 输入侧由 Extractor / Evaluator / Validator 各自识别、转换为共享 enum;输出侧由 QuestionStrategy 把 payload 渲染成贴合图类型的自然语言。
六、一个完整 case · 5 轮对话
css
Turn 1 User: "画个用户注册流程时序图"
MetaExtractor → { type: Sequence, confidence: HIGH, source: USER_EXPLICIT }
Extractor → { actors: ["用户"], events: [], sync: unknown }
Evaluator → [actors_count G1, events G1, sync G2]
Planner(budget=5) → AskClarification(actors_count)
Agent: "除了用户,还有哪些角色参与?"
[budget: 5 → 4]
Turn 2-3 User: 前端 / 后端 / 数据库,以及各步骤
Planner 逐轮问 events / sync
[budget: 4 → 3 → 2]
Turn 4 User: "先画看看" ← SkipToGenerate,不消耗 budget
Generator #1 → 惯性加"发邮件通知"(spec / assumptions 都没有)
Validator 语法校验 → FAIL(未声明的邮件系统)
内层修复循环触发 → Generator #2 → 去掉邮件 → 语法 OK
Validator 语义校验 → 3 个未声明的响应箭头 → HIGH 擅自添加
→ 挂 pending Confrontation
输出:图 + Confrontation "我加了完整响应流程,想保留吗?"
Turn 5 User: "响应不要,只画到存数据库就行" ← ConfrontationAnswer NO
写入负事实:events_negative.append({action: "响应流程"})
触发 regenerate → 无响应箭头 → 呈现新图
[budget 不变,Confrontation = 0]
5 轮预算,Agent 实际主动追问 3 次,剩余 2 轮备用。这就是全局预算的弹性 ------ Planner 知道什么时候要收敛,不会把用户问烦。
七、几个取舍
LLM 场景下"纯函数"是近似真,不是数学真。Extractor 描述我一开始写的是"精确幂等",跑了几次发现 LLM 每次输出都有小方差,重跑结果不完全一致 ------ 用户会觉得"Agent 忘了我上一轮说过的话"。工程上用低 temperature + 严格 schema + 每轮 diff 提示("根据你最新的说法,我重新理解了整段对话")能缓解,但架构层的诚实做法是承认这一点。描述改成"结构近似幂等,语义相似性尽力而为"。
Confrontation "否" 的默认值取 LLM top-1,不引入业务权重。MetaExtractor 会给多个候选(比如"这可能是时序图 / 状态图 / 流程图"),置信度最高的作为默认。不引入业务规则加权(比如"程序员多写时序图,给 sequence 加权"),保持 debug 简单 ------ 出错时你知道模型给的原始分布是啥,不用查规则表。
类型中途切换用 Rescope,不删对话历史,只重跑 Extractor。用户 Turn 3 说"换成状态图吧",累积的 SequenceSpec 怎么办?三个候选:Discard(丢弃)简单但信息白费用户会怒;Migrate(映射)N² 映射规则,错映射比丢弃还糟;Rescope 保留 conversation_history + ask_log + budget,清空 spec + render_context + pending_confrontations,重跑 StateExtractor 派生新 spec。
核心洞察:conversation_history 是唯一权威,spec 是从历史派生出来的视图。换图类型 = 换派生视图,历史不动。
八、如果你也在做多轮 LLM Agent
不管你做的是画图、写 SQL、生成 API mock,这三条我觉得最能省事:
两层嵌套 Loop 拆开做。"问清楚"和"画出来"是两件事,共享数据但目标不同。混在一起会陷入"这个歧义要问吗"的死循环。
Extractor 保守 + Generator 可以猜,分工严格。这是"擅自检测能工作"的地基。Extractor 帮用户补常识就等于把 Agent 的判断力交给了 LLM 的直觉;Generator 的每个 assumption 必须留档 render_context.assumptions,Validator 才能对账。
所有 policy 集中在一个 Planner 纯函数里。想调追问节奏、改预算策略、加分段预算 ------ 只动一处代码。别的角色都是"识别问题",只有 Planner 做"决定要不要问"。工程收益(可回放 / 可测 / 可 fork)三条全靠这个分工吃到。
工具体验地址text2everything.vip