
简历工具这个赛道表面上看已经被做烂了——在线简历生成器、简历模板站、AI润色工具一抓一大把。但真正动手做过的人都知道这里面有个绕不开的坎简历不是写出来的是改出来的。用户拿着一份旧简历想针对某个具体岗位做定向优化这个过程涉及信息提取、岗位匹配、内容重写、格式调整、多轮迭代每一步都有上下文依赖用传统的表单模板思路根本做不顺。我这次用 Next.js 做前端、LangGraph.js 做 Agent 编排把整个简历优化流程拆成了一条可回溯、可中断、可人工介入的状态图跑通之后发现这套架构的复用价值远超简历这一个场景。这篇文章不讲概念只讲我实际落地时踩过的坑和最终跑通的方案。适合已经会用 Next.js、对 LLM 调用有基本认知、但还没真正把 Agent 编排落地到生产项目的开发者。如果你正在纠结AI Agent 到底怎么搭才不是玩具或者想知道 LangGraph.js 在真实业务里怎么用下面的内容应该能帮你省掉至少两周的试错时间。1. 为什么简历工具是验证 Agent 编排的绝佳场景1.1 简历优化的本质是一条有状态的工作流大多数人第一次做 AI 简历工具思路都很直接把简历文本丢给大模型加一句帮我优化然后等结果。我一开始也是这么干的结果发现三个致命问题。第一单次调用无法承载多阶段任务。简历优化至少要经过解析原始简历→提取关键信息→分析目标岗位→匹配差距→重写内容→校验格式这几个阶段每个阶段的输入依赖上一个阶段的输出而且中间任何一步出错后面全崩。你不可能用一次 prompt 把所有事都办了就算勉强塞进去模型也会顾此失彼。第二用户需要在中途介入。比如 Agent 提取出的工作经历有遗漏或者对某段经历的改写方向不对用户得能喊停、能修改、能重新走。传统的一次性调用是黑盒用户只能看到最终结果不满意就重来体验极差。第三失败需要可恢复。大模型调用会超时、会返回格式错误、会触发限流。如果整个流程是一个大函数任何一步失败都得从头再来token 成本和用户等待时间都受不了。这三个问题指向同一个答案你需要一个状态机来编排整个流程。而 LangGraph.js 恰好就是干这个的——它把 Agent 的每一步建模成图上的节点节点之间通过边连接状态在节点间流转天然支持条件分支、中断恢复和人工介入。1.2 LangGraph.js 相比链式调用的核心差异很多人用过 LangChain 的 Chain觉得链式调用不也能串起来吗。能串但链式调用是线性的、单向的一旦走进去就只能往前。LangGraph 的核心区别在于它引入了图结构和状态持久化。图结构意味着你可以有分支。比如简历解析完之后根据简历类型应届生/社招/转行走不同的优化策略这在 Chain 里要靠 if-else 硬编码在 Graph 里就是一条条件边。状态持久化意味着每个节点的执行结果都会被 checkpoint 保存流程中断后可以从任意一个 checkpoint 恢复这对需要人工审核的环节至关重要。我实测下来LangGraph.js 最实用的三个能力是条件路由根据状态决定下一步走哪、中断与恢复interrupt resume、状态累积每个节点往共享状态里追加数据而不是覆盖。这三点直接决定了你的 Agent 能不能处理真实业务而不是只能跑 demo。1.3 Next.js 在这个架构里承担什么角色前端选 Next.js 不是跟风。简历工具的核心交互是上传→预览→编辑→导出这里面有大量需要服务端参与的逻辑文件解析、LLM 调用、状态管理。Next.js 的 App Router 配合 Route Handlers可以把这些逻辑放在服务端前端只负责渲染和交互。更关键的是Server-Sent EventsSSE。Agent 执行是流式的用户需要实时看到正在解析简历正在匹配岗位正在重写第三段经历这样的进度反馈。Next.js 的 Route Handler 天然支持流式响应配合 LangGraph 的 stream 模式可以把每个节点的执行状态实时推给前端。这个体验差距是巨大的——用户等 30 秒看到进度条在动和盯着空白页等 30 秒感受完全不同。另外 Next.js 的 Server Actions 可以用来处理表单提交和文件上传和 Agent 的调用解耦得很干净。整个架构就是前端负责交互和展示Route Handler 负责和 LangGraph 通信LangGraph 负责编排 Agent 逻辑。2. 环境搭建与项目骨架那些文档不会告诉你的细节2.1 依赖版本的选择与锁定LangGraph.js 这个库迭代很快API 在不同小版本之间会有 breaking change。我踩过的坑是本地开发用的 0.2.x部署到服务器时 npm 自动装了 0.3.x结果StateGraph的构造方式变了直接报错。所以第一件事就是锁死版本。{ dependencies: { next: 14.2.5, langchain/langgraph: 0.2.34, langchain/openai: 0.3.14, langchain/core: 0.3.21, zod: 3.23.8 } }这里有几个选择理由要说清楚。langchain/core必须显式安装因为 LangGraph 依赖它但不会自动帮你装对版本。zod是用来定义状态 schema 的LangGraph.js 用 zod 来做运行时校验这个后面会详细讲。Next.js 选 14.2.x 是因为 App Router 在这个版本已经稳定15 虽然出了但生态还在追生产项目没必要当小白鼠。提示如果你用 pnpm记得在.npmrc里加上save-exacttrue避免^号带来的版本漂移。这个细节在多人协作时特别重要。2.2 目录结构的设计逻辑项目结构不是随便分的它直接反映了架构分层。我的结构是这样的src/ app/ api/ agent/ route.ts # Agent 流式接口 upload/ route.ts # 文件上传与解析 resume/ page.tsx # 简历工作台页面 lib/ agent/ graph.ts # LangGraph 图定义 nodes/ # 各个节点实现 parse.ts analyze.ts rewrite.ts validate.ts state.ts # 状态 schema 定义 llm/ client.ts # LLM 客户端封装 parser/ pdf.ts # PDF 解析 docx.ts # Word 解析 components/ ResumeEditor.tsx ProgressStream.tsx关键点是lib/agent/nodes/这个目录。每个节点单独一个文件好处是节点逻辑可以独立测试而且当图变复杂时不会变成一个几千行的巨型文件。我见过有人把所有节点写在一个 graph.ts 里后期维护简直是灾难。state.ts单独抽出来也很重要。状态 schema 是整个 Agent 的契约所有节点都依赖它。把它独立出来任何节点改动都能立刻对照检查是否破坏了契约。2.3 环境变量的安全处理LLM 的 API Key 绝对不能出现在客户端。Next.js 里有个坑只有以NEXT_PUBLIC_开头的环境变量才会暴露给浏览器其他的只在服务端可用。所以你的 Key 命名千万不要加NEXT_PUBLIC_前缀。# .env.local OPENAI_API_KEYsk-xxx OPENAI_BASE_URLhttps://api.xxx.com/v1 LLM_MODELgpt-4o-mini这里我建议把base_url也做成环境变量因为不同环境可能用不同的模型服务。另外LLM_MODEL单独抽出来方便在不改代码的情况下切换模型——调试阶段用便宜的小模型上线再换大的这个灵活性很值。注意.env.local一定要写进.gitignore。我见过有人把 Key 提交到仓库虽然可以撤销但已经泄露的 Key 必须立刻作废这个教训太贵了。3. 用 Zod 定义 Agent 状态整个系统的地基3.1 状态 schema 应该包含什么LangGraph 的核心是状态在节点间流转所以状态定义是整个系统的地基。我用 zod 定义了一个ResumeState包含这几类字段import { z } from zod; export const ResumeStateSchema z.object({ // 输入 rawResume: z.string().default(), targetJob: z.string().default(), // 解析结果 parsedSections: z.object({ basic: z.string().default(), experience: z.array(z.string()).default([]), skills: z.array(z.string()).default([]), education: z.array(z.string()).default([]), }).default({}), // 分析结果 jobRequirements: z.array(z.string()).default([]), gapAnalysis: z.string().default(), // 重写结果 rewrittenSections: z.record(z.string()).default({}), // 流程控制 currentStep: z.string().default(init), errors: z.array(z.string()).default([]), retryCount: z.number().default(0), }); export type ResumeState z.infertypeof ResumeStateSchema;设计这个 schema 有几个原则。第一所有字段都要有默认值。LangGraph 初始化状态时不会自动填充如果某个字段没默认值节点访问时会 undefined 报错。第二用数组而不是字符串存列表类数据比如工作经历用string[]这样节点可以追加而不是覆盖。第三流程控制字段单独分组currentStep、errors、retryCount这些是给图逻辑用的和业务数据分开更清晰。3.2 为什么用 zod 而不是 TypeScript interface有人会问TypeScript 的 interface 不也能定义类型吗为什么要用 zod答案是运行时校验。TypeScript 的类型在编译后就消失了而 LLM 返回的数据是运行时才产生的你无法保证它符合类型。zod 可以在节点里对 LLM 的输出做校验不符合就抛错或走重试分支。const llmOutput await llm.invoke(prompt); const parsed ResumeStateSchema.partial().safeParse(llmOutput); if (!parsed.success) { return { errors: [...state.errors, parsed.error.message] }; }这个模式我在每个涉及 LLM 输出的节点里都用。实测下来LLM 返回格式错误是家常便饭尤其是要求返回 JSON 的时候它经常给你包一层 markdown 代码块。有了 zod 校验至少能第一时间发现问题而不是让错误数据流到下游。3.3 状态更新的 reducer 机制LangGraph 有个很重要的概念叫reducer。默认情况下节点返回的对象会覆盖状态里的同名字段。但有些字段你希望是追加比如errors数组。这时候就要用Annotation指定 reducer。import { Annotation } from langchain/langgraph; const StateAnnotation Annotation.Root({ errors: Annotationstring[]({ reducer: (existing, update) [...existing, ...update], default: () [], }), // 其他字段用默认的覆盖行为 currentStep: Annotationstring(), });这个机制我一开始没搞懂导致errors每次都被覆盖调试时看不到完整的错误链。后来才明白凡是需要累积的字段都必须显式指定 reducer。这个坑很隐蔽因为不报错只是数据悄悄丢了。4. 图结构设计把简历优化拆成可回溯的节点4.1 节点划分的粒度怎么把握节点划分太粗一个节点干太多事出错难定位太细节点间通信开销大图变得复杂难维护。我的经验是按职责单一且输出可校验来划分。最终我拆了五个核心节点节点名职责输入输出parse解析原始简历为结构化数据rawResumeparsedSectionsanalyze分析岗位要求并做差距分析parsedSections, targetJobjobRequirements, gapAnalysisrewrite按差距重写简历内容parsedSections, gapAnalysisrewrittenSectionsvalidate校验重写结果的完整性和格式rewrittenSectionserrors 或通过finalize组装最终简历rewrittenSections最终输出这个粒度是我迭代了三次才定下来的。最初我把 analyze 和 rewrite 合成一个节点结果发现分析阶段需要用户确认比如用户可能不认同差距分析必须拆开才能插入人工审核。后来我又想把 validate 拆成格式校验和内容校验两个节点但发现它们共享大量上下文拆开后状态传递反而更麻烦就合并了。4.2 条件边的设计让流程会拐弯图的价值在于分支。我的图里有三个关键的条件边。第一个是解析后的分支如果解析失败比如 PDF 是扫描件提取不出文字直接走错误处理节点提示用户手动输入。如果解析成功走 analyze。第二个是分析后的分支如果差距分析显示匹配度很高比如超过 80%可以跳过大规模重写只做微调如果匹配度低走完整重写流程。这个判断用一个简单的评分节点来做。第三个是校验后的分支validate 节点如果发现错误根据retryCount决定是重试 rewrite 还是直接报错给用户。这里要防止无限重试所以设了最大重试次数 3 次。graph.addConditionalEdges(validate, (state) { if (state.errors.length 0) return finalize; if (state.retryCount 3) return fail; return rewrite; });这个retryCount的递增要在 rewrite 节点里做每次进入就加一。我一开始忘了加结果校验失败后无限循环把 token 烧了一大截才发现。4.3 中断点的设置让人工介入变得自然LangGraph 的interrupt机制是我最喜欢的功能。在 analyze 节点之后我设置了一个中断点流程会暂停把差距分析结果返回给前端等用户确认或修改后再 resume。import { interrupt } from langchain/langgraph; async function analyzeNode(state: ResumeState) { const analysis await doAnalysis(state); const userFeedback interrupt({ type: confirm_analysis, data: analysis, }); return { gapAnalysis: userFeedback.approved ? analysis : userFeedback.modified, }; }这个模式的关键是中断不是异常是正常流程的一部分。前端收到中断信号后渲染一个确认界面用户操作完把结果传回来图从断点继续。整个过程状态是持久化的即使用户关掉页面明天再来只要 checkpoint 还在就能接着走。我实测下来这个中断点放在 analyze 之后是最合理的。放太早parse 之后用户还没看到有价值的信息确认没意义放太晚rewrite 之后用户已经等太久改起来成本高。5. 各节点的实现细节与踩坑记录5.1 parse 节点PDF 解析的坑比想象中多简历文件格式五花八门PDF、Word、甚至图片。我优先支持 PDF 和 Word图片走 OCR 但准确率不稳定暂时作为降级方案。PDF 解析我用的是pdf-parse但这个库有个坑它对某些 PDF 的文本提取会乱序尤其是多栏排版的简历。我试过pdfjs-dist效果好一些但配置复杂。最终方案是先用pdf-parse如果提取出的文本行数异常少比如少于 10 行就判定为解析失败提示用户换格式。import pdf from pdf-parse; async function parsePDF(buffer: Buffer): Promisestring { const data await pdf(buffer); const lines data.text.split(\n).filter(l l.trim()); if (lines.length 10) { throw new Error(PDF_PARSE_FAILED); } return data.text; }Word 解析用mammoth这个库比较稳能把 docx 转成纯文本保留基本的段落结构。注意mammoth默认不处理表格如果简历里有表格形式的经历需要额外配置。解析完之后还要用 LLM 把纯文本切成结构化字段。这一步的 prompt 要写得很具体明确告诉模型每个字段的格式要求并且要求返回 JSON。我用的 prompt 大概是这样的你是一个简历解析器。请把下面的简历文本解析成 JSON 格式包含以下字段 - basic: 姓名、联系方式等基本信息字符串 - experience: 工作经历数组每项包含公司、职位、时间、描述 - skills: 技能数组 - education: 教育经历数组 只返回 JSON不要任何解释。如果某个字段在简历中不存在返回空数组或空字符串。即便如此模型偶尔还是会返回带 markdown 代码块的结果。所以我在解析前先做一次清洗把json 和去掉。5.2 analyze 节点差距分析怎么做才有价值差距分析是简历工具的核心价值点。用户想知道的是我离这个岗位还差什么而不是笼统的你的简历需要优化。我的做法是先把岗位描述JD也做一次结构化解析提取出硬性要求学历、年限、特定技能和软性要求沟通能力、团队协作。然后和简历的 parsedSections 做逐项对比输出一个差距列表。const analysisPrompt 岗位要求${JSON.stringify(jobRequirements)} 候选人简历${JSON.stringify(parsedSections)} 请分析候选人与岗位的匹配情况输出 1. 已满足的要求列表 2. 未满足或部分满足的要求列表 3. 针对每项差距给出简历修改建议 以 JSON 格式返回。 ;这里有个经验不要让模型直接给匹配度百分比。模型给的百分比没有依据用户也不信。改成列出具体的满足项和差距项用户一看就明白信任度高得多。另外差距分析的结果要缓存。同一个简历配同一个岗位分析结果应该是一致的没必要每次重新调用。我用简历内容的 hash 加岗位 hash 作为 key存在内存缓存里命中率还挺高。5.3 rewrite 节点重写不是让模型自由发挥重写节点最容易失控。如果你只给模型一句帮我优化这段经历它会给你编出一堆简历上根本没有的东西。这在简历场景是致命的——用户拿着编造的简历去面试一问就露馅。我的做法是强约束重写只允许模型基于已有信息重新组织表达不允许添加新的事实。prompt 里明确写重写以下工作经历要求 1. 只能使用原文中出现的信息不得添加任何新的事实、数据或成果 2. 用更专业的动词开头突出职责和成果 3. 每条经历控制在 2-3 句话 4. 如果原文信息不足以支撑重写保持原样并标记 [信息不足] 原文${experience}这个[信息不足]标记很关键。它让模型在信息不够时诚实地说出来而不是硬编。前端拿到这个标记后会高亮提示用户补充信息形成一个正向循环。重写还要分段进行不要一次性重写整份简历。我按 experience、skills、education 分别调用每段独立重写。这样做的好处是单次调用的上下文更聚焦输出质量更高而且某一段失败不影响其他段可以单独重试。5.4 validate 节点校验什么怎么校验validate 节点做三件事完整性校验重写后的简历是否包含所有必要字段、一致性校验重写内容是否和原文矛盾、格式校验是否符合输出格式要求。完整性校验用 zod 直接做检查必填字段是否为空。一致性校验比较麻烦我的做法是用一个轻量级的 LLM 调用把原文和重写结果一起给它问重写结果是否引入了原文没有的事实。这个调用用便宜的小模型就行不需要大模型。const consistencyPrompt 原文${original} 重写后${rewritten} 重写后的内容是否引入了原文中没有的事实只回答 YES 或 NO并列出具体的新增事实如果有。 ;格式校验主要是检查有没有残留的 markdown 标记、有没有超长段落等。这些用正则就能搞定不需要 LLM。校验失败时把具体错误写进state.errors然后根据 retryCount 决定是否重试。这里要注意重试时要带上上次的错误信息让模型知道哪里错了否则它会犯同样的错误。6. 前端流式交互让用户看见 Agent 在干活6.1 SSE 接口的实现Agent 执行是流式的前端要实时显示进度。我用 SSE 来实现Route Handler 返回一个ReadableStream。export async function POST(req: Request) { const { resumeText, targetJob } await req.json(); const stream new ReadableStream({ async start(controller) { const encoder new TextEncoder(); for await (const event of graph.stream({ rawResume: resumeText, targetJob })) { const data data: ${JSON.stringify(event)}\n\n; controller.enqueue(encoder.encode(data)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里有个坑Next.js 的 Route Handler 默认会缓冲响应导致 SSE 不实时。必须加上Cache-Control: no-cache和Connection: keep-alive并且在部署时确保反向代理不缓冲Nginx 需要配置proxy_buffering off。6.2 前端如何消费流前端用EventSource或者fetch的流式读取。我用的后者因为需要 POST 请求EventSource只支持 GET。const response await fetch(/api/agent, { method: POST, body: JSON.stringify({ resumeText, targetJob }), }); const reader response.body?.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader!.read(); if (done) break; const chunk decoder.decode(value); const lines chunk.split(\n\n).filter(Boolean); for (const line of lines) { if (line.startsWith(data: )) { const event JSON.parse(line.slice(6)); handleEvent(event); } } }handleEvent根据事件类型更新 UI。比如收到{ node: parse, status: running }就把解析简历这一步标为进行中收到{ node: parse, status: done }就标为完成。6.3 中断恢复的前端处理当 Agent 触发 interrupt 时SSE 会推送一个特殊事件。前端收到后弹出确认界面用户操作完再发一个 resume 请求。if (event.type interrupt) { setPendingInterrupt(event.data); // 用户确认后 await fetch(/api/agent/resume, { method: POST, body: JSON.stringify({ threadId: event.threadId, feedback: userFeedback }), }); }threadId是 LangGraph 用来标识一次会话的checkpoint 就是按 threadId 存的。这个 ID 要在前端保存好刷新页面后还能恢复。7. 并发与性能Agent 扛并发的真实经验7.1 单次 Agent 执行的耗时拆解先看数据。我实测一次完整的简历优化流程各阶段耗时大概是阶段平均耗时占比文件解析0.5s3%parse 节点LLM3s18%analyze 节点LLM4s24%rewrite 节点LLM分段6s35%validate 节点2s12%其他1.5s8%合计约 17s100%17 秒对单个用户来说可以接受有进度反馈但如果 100 个用户同时提交就是 1700 秒的 LLM 调用量。所以并发优化的核心是减少 LLM 调用次数和时长。7.2 三个立竿见影的优化手段第一rewrite 节点并行化。experience、skills、education 三段重写是独立的可以并行调用。我用Promise.all把 6 秒压到了 2.5 秒左右。const [expResult, skillResult, eduResult] await Promise.all([ rewriteSection(experience, state), rewriteSection(skills, state), rewriteSection(education, state), ]);第二缓存差距分析。前面提过同简历同岗位的分析结果可以缓存。我用的 key 是hash(resume) hash(job)缓存有效期设 24 小时。实测缓存命中率在 30% 左右因为很多用户会反复调整同一份简历。第三小模型做校验。validate 节点的一致性校验用 gpt-4o-mini 就够了比用大模型省 80% 的成本和一半的时间。7.3 限流与队列别让 LLM 把你限流了LLM 服务商都有速率限制。我一开始没做限流高峰期直接被限流大量请求失败。后来加了一个简单的令牌桶限流器。class RateLimiter { private tokens: number; private lastRefill: number; constructor(private maxTokens: number, private refillRate: number) { this.tokens maxTokens; this.lastRefill Date.now(); } async acquire(): Promisevoid { this.refill(); if (this.tokens 0) { await new Promise(r setTimeout(r, 100)); return this.acquire(); } this.tokens--; } private refill() { const now Date.now(); const elapsed (now - this.lastRefill) / 1000; this.tokens Math.min(this.maxTokens, this.tokens elapsed * this.refillRate); this.lastRefill now; } }这个限流器是进程内的单实例够用。如果多实例部署需要换成 Redis 做分布式限流。我目前是单实例还没遇到瓶颈。另外对于超出限流的请求不要直接失败而是排队。我用了一个简单的内存队列请求进来先入队限流器有空位就出队执行。队列长度设上限超过就返回当前繁忙请稍后重试。8. 部署与监控上线后才发现的那些问题8.1 部署环境的坑Next.js 部署到服务器最容易出问题的是文件上传大小限制。简历文件一般不大但有些用户会上传几十页的作品集 PDF。Next.js 的 Route Handler 默认有 body size 限制需要在配置里调大。export const config { api: { bodyParser: { sizeLimit: 10mb, }, }, };另外SSE 长连接在负载均衡器后面容易被断开。如果你的部署架构有 Nginx记得配置proxy_read_timeout调大否则 Agent 跑到一半连接被掐断用户看到的就是卡住了。8.2 监控什么指标上线后我重点监控四个指标Agent 成功率走完全流程没报错的比例、各节点平均耗时、LLM 调用失败率、中断恢复成功率。Agent 成功率是最核心的。我一开始的成功率只有 70% 左右主要失败原因是 LLM 返回格式错误和 PDF 解析失败。加了 zod 校验和重试机制后提升到了 92%。剩下的 8% 主要是用户上传的简历格式太特殊这个短期内无解只能引导用户手动输入。各节点耗时用来定位性能瓶颈。我发现 rewrite 节点偶尔会耗时超过 15 秒排查后发现是某段经历特别长模型生成的内容太多。后来加了输出长度限制问题解决。8.3 日志与可观测性Agent 的调试比普通接口难因为流程长、状态多。我的做法是每个节点执行前后都打日志记录输入状态的关键字段和输出。async function withLoggingT( nodeName: string, fn: () PromiseT ): PromiseT { const start Date.now(); console.log([${nodeName}] start); try { const result await fn(); console.log([${nodeName}] done in ${Date.now() - start}ms); return result; } catch (error) { console.error([${nodeName}] failed:, error); throw error; } }这个包装函数让每个节点的执行都有迹可循。配合 threadId可以完整还原一次 Agent 执行的全过程。这个在排查用户反馈的问题时特别有用。9. 这套架构还能怎么扩展简历工具只是这套架构的一个应用。LangGraph Next.js 的组合本质上解决的是多阶段、有状态、需人工介入的 AI 工作流这个通用问题。我目前已经在考虑把它迁移到另外两个场景。一个是合同审查。流程和简历优化高度相似解析合同→提取关键条款→对比标准模板→标记风险点→人工确认→生成审查报告。节点划分几乎可以照搬只是 prompt 和校验规则不同。另一个是内容创作助手。从选题→大纲→初稿→润色→事实核查也是一条有状态的工作流中间需要作者多次介入调整。LangGraph 的中断机制在这里同样适用。扩展的关键是把业务逻辑和编排逻辑解耦。我的节点实现里业务逻辑prompt、校验规则和编排逻辑图结构、条件边是分开的。换一个场景只需要替换节点实现图结构基本不用动。这个解耦是我在项目中期重构时做的前期图省事把两者混在一起后期改起来很痛苦。如果你也在做类似的 AI 工作流项目我的建议是先把状态 schema 设计好再画图最后写节点。这个顺序不能反。状态是契约图是骨架节点是血肉。契约没定好后面全是返工。