n8n 多 Agent 内容流水线:写手 → 审核 → SEO 检查 → 发布(完整工作流 JSON)
目录
上周二,我从一张 Google 表格里一次性把 14 篇博客发完了。一篇都不是我亲手写的。整个流程——从选题简报到初稿、审稿、SEO 检查、WordPress 发布,再到 Slack 通知——一共跑了 41 分钟,OpenAI 和 Anthropic 的 API 账单加起来是 7.80 美元。跑这个流程的 n8n 工作流已经稳定运行了 9 个月,中间只做过几次小修。
它不是一段"超级提示词"(mega-prompt)。它是四个 Agent 串行排列,每个 Agent 职责单一、输出结构化、失败可重试。整条流水线能在一张 n8n 画布上画完,但关键在于:Agent 之间流转的不是一段 Markdown 加一个"祝我好运",而是一份真正的状态结构(schema)。
这篇文章就是把这套东西完整拆给你看——架构、提示词、能直接粘贴到 n8n 的 JSON,以及那些我真实踩过坑的失败模式。如果你是个对 API Key 和 JSON 都不发怵的营销人,这周五之前你就能跑起来。
为什么是多 Agent,而不是一个超长提示词
一段"写一篇 2000 字 SEO 优化博客"的超长提示词,最终只会产出两种结果:要么是你从头重写一遍的稿子,要么是你巴不得自己重写一遍的稿子。过去两年我把这种提示词喂给了所有主流模型,方差大得离谱——有时候交回来的文章很紧实,有时候会漂进 LinkedIn 那种"让我们一探究竟"的味道,而且你事前根本判断不出会拿到哪一份。
解法是拆工。一个只负责写初稿的写手 Agent,给到简报和风格指南就开干。一个只读初稿、产出结构化审稿意见的审核 Agent。一个只检查页内 SEO 因素并返回评分的 SEO Agent。一个拿到合格稿件、负责发货的发布 Agent。每个 Agent 输出都很小、定义清楚;每个 Agent 都能独立测试;任何一个都可以换成别的模型而不影响其他 Agent。
你可以把它想象成一条流水线。写手是装配工位,审核是 QA,SEO 检查是法规检验员,发布是物流。装配工位哪天状态不好,检验员能在它出车间前拦住。检验员太严,装配工位就带着反馈回炉。它们谁也不需要知道别人怎么干——只需要读那个状态文件。
这种解耦也是便宜的原因。写手烧的 token(模型按"词片"收费,1 token ≈ 0.75 个英文单词)最多。审核很小。SEO 检查基本是查清单。发布一个字都不写。一篇 2000 字的稿子,写手一轮大约 0.45 美元,审核 0.08 美元,SEO 0.05 美元。超长提示词那种只要 0.30 美元——但你接下来要重写一个小时,相当于给自己开了 60 美元的时薪。从第二篇稿子开始,流水线就在用你的人力时间给自己回本。
一段话讲清楚架构
Google 表格的一行就是一份简报:目标关键词、目标字数、受众、必备章节、要插入的内链、品牌风格指南 URL,外加一个状态列。n8n 定时轮询这张表,看到 status = "queued" 的行就锁定,跑完四个 Agent,再把结果回写到这一行。最终目的地是 Notion 数据库(你也可以换成 WordPress、Ghost、Webflow,或者任何带 HTTP API 的 CMS)。Slack 在每篇发车时收到一条通知,带上草稿链接和 SEO 评分。
整个状态是一个 JSON 对象。每个 Agent 从状态里读自己需要的东西,把自己的产出写回去,下一个 Agent 接住。没有共享内存,没有向量数据库(vector DB,把文字转成"嵌入向量"以便按语义而非关键词检索的数据库),没有任何花哨的编排框架。就是一个会随着流水线推进而长大的 JSON 对象。
Agent 1:写手
写手的活儿就是拿简报、出初稿。它是唯一一个写长文的 Agent。其他的要么挑刺,要么搬运文字。
提示词分四段:
- 角色——"你是一位服务于 [品牌] 的高级 B2B(business-to-business,企业对企业)内容写手,写作风格以 [URL] 风格指南为准。"
- 输入——表格里的简报(关键词、字数、章节、链接)。
- 约束——白纸黑字:H2 不许用反问开场、不许写"在这个快节奏的时代"这种开头、不许编造统计数据、行内给引用来源、结尾的 CTA(Call to Action,行动号召,告诉读者下一步做什么)必须符合页面模板。
- 输出契约——一个 JSON 对象,字段为
title、metaDescription、slug、bodyMarkdown、internalLinksUsed[]、externalSources[]、wordCount。
输出契约是重点。写手被要求返回 JSON 而非 Markdown。流水线解析 JSON,bodyMarkdown 字段是后面所有 Agent 看到的内容。如果模型把 JSON 用反引号包起来(头几次一定会),解析器会剥掉。如果 JSON 格式不对,工作流直接报错,然后用一句修正型 system 消息重试:"你上一轮的回复不是合法 JSON,请只返回 JSON 对象,不要带 Markdown 格式。"
几个关键细节:
- 写手用
temperature: 0.7。低一点会出"扁平"的散文,高一点容易跑偏,0.7 是初稿的甜区。 - 输入控制在 8000 token 以内。一份精炼的写作风格指南加上一份简报大约 2000 token;如果风格指南超过 8000 token,写手反正也不会真按它写——超过这个长度,注意力就散了。
max_output_tokens设 4000。2000 字的稿子大约 2700 token,4000 留点富余。如果模型想写更多,它也写不出来——这反而是好事,写手最常见的失败就是字数失控。- **重试时不带上一版草稿。**如果写手第一轮写得烂,相同提示词下的第二轮通常还是烂。我换模型(GPT-4o 不行就换 Claude Sonnet,反之亦然),再把"上一版哪里不对"用一句话塞进去。
Agent 2:审核
审核的活儿是读初稿、出结构化审稿意见。它不重写,只挑问题。
提示词分三段:
- 角色——"你是 [品牌] 的高级编辑,有 15 年编辑经验。严厉但公平。"
- 输入——简报、风格指南、写手的初稿。
- 输出契约——一个 JSON 对象,字段为
overallScore(1–10)、pass(布尔值,≥ 7 即通过)、issues[](每条 issue 含severity、location、description、suggestedFix)、summary(一段话)。
审核被要求诚实。及格线是 7 分(满分 10)。如果低于 7,工作流把 issues 列表追加到简报里、退回给写手。写手有一次重试机会。第二轮还不过,状态就标 needs-human,Slack 通知一个人来读。这种情况我一般直接重写——这时候模型花的 token 已经比我手打一遍还多了。
审核是我最不放心的 Agent,所以我用当下能力最强的模型。英文散文用 Claude Opus,中文用 Claude Sonnet。审核得真的懂写作质量——句子节奏、论证结构、段落统一性——小模型抓不住这些。
有个细节很要命:审核的 issues[] 就是写手重读时看到的东西。工作流不会重生成整段提示词——只把 issues 拼到原始简报末尾,再把一个 revision: 2 的标记打开。写手看到的是类似"上一版稿件审核后有以下问题:……"这样的输入。这一招带来的是可量化的提升——重写那一版评分通常会高 1.5 到 2 分。
Agent 3:SEO 检查
SEO(Search Engine Optimization,搜索引擎优化,让页面在 Google 排名更高)的活儿是按一份固定的页内检查清单给文章打分,返回一份 JSON 报告。它不是创造性任务,它就是正则(regex,按模式匹配文本的工具)和模板匹配。
提示词分三段:
- 角色——"你是一名 SEO 分析师,按一份固定的页内 SEO 清单给页面打分。"
- 输入——简报(目标关键词、目标字数、必备章节)、初稿。
- 输出契约——一个 JSON 对象,字段为
score(0–100)、checks[](每条 check 含name、passed、detail)、recommendations[](一组具体可执行的修正建议)。
清单写死在提示词里。它和我手工审核用的清单基本一样,大概是:
- 目标关键词出现在标题、H1、前 100 字、后 100 字、meta description、URL slug
- 关键词密度 0.5%–2.5%
- 字数在目标的 ±15% 之内
- H2 数量在 3 到 8 之间
- 每个 H2 都含数字、问题或强动词
- 至少 2 个内链、至少 1 个指向权威来源的外链
- meta description 长度 140–160 字符
- 段落不超过 4 句,句子不超过 35 词
- 每个章节都要回答目标关键词背后的搜索意图
模型本身不打分。它返回一个 JSON,描述它发现了什么,然后一个 n8n 的 Code 节点做真正的正则/字符串匹配打分。模型是解析器,不是裁判。这个区分很关键——模型擅长判断关键词出现在哪、meta description 长度合不合适,但"密度是否落在 0.5%–2.5% 之间"这种布尔数学它做不好。让确定性的代码去处理。
工作流接着把 score 和 recommendations[] 回写到表格那一行。如果分数低于 70,文章在表格里被打上 seo-needs-work 的标记。文章照发,但人在队列里能看到这个旗标。我没遇到过一篇低于 70 又不需要真人动手的稿子——到这个分,简报通常就错了,不是稿件的事。
Agent 4:发布
发布是最"笨"的 Agent,也是我最信任的。它把审核通过的稿件格式化到目标 CMS(Content Management System,文章实际住的后台),然后推过去。
对接 Notion 的话,发布会:
- 在数据库里新建一个 page,含
title、slug、status: "draft"(在 Notion 里还是草稿——人最后按发布)。 - 上传封面图(由另一条封面图流水线单独生成)。
- 把正文按 Notion block 切分插入(paragraph、heading_2、heading_3、bulleted_list_item、code、image、quote——这些是 Notion block 类型)。
- 写入
SEO Score、Review Score、Target Keyword三个属性。 - 在
#content-shipped这个 Slack 频道发一条消息,附 Notion 页面链接。
对接 WordPress 用 REST API(一种让两个软件通过 URL 互相调用的方式)+ 应用密码;对接 Ghost 用 Admin API;对接 Webflow 用 CMS API。同一个 Agent,四个适配器。
一个安全细节:发布从不删除或更新。它只创建。如果目标位置已经存在,发布直接报错并停手。这是有意为之——我不想让工作流的二次运行悄悄覆盖人类已经编辑过的文章。slug 是唯一键,slug 存在就大声失败。
完整工作流 JSON
下面这段 JSON 就是 n8n 的工作流定义。在 n8n 里通过 Workflows → Import from File 导入,或者把 JSON 复制到剪贴板按 Ctrl+O。凭证(OpenAI、Anthropic、Notion、Slack、Google Sheets)按 ID 引用——把 YOUR_* 占位符替换成 n8n 凭证管理器里你自己凭证的 ID。
json{
"name": "Content Pipeline — Writer → Reviewer → SEO → Publisher",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "*/15 * * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Every 15 min",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [240, 300]
},
{
"parameters": {
"operation": "readRows",
"documentId": {
"__rl": true,
"value": "YOUR_GSHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Content Queue",
"mode": "name"
},
"filters": {
"status": "queued"
}
},
"id": "gsheet-read",
"name": "Read queued rows",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [460, 300]
},
{
"parameters": {
"conditions": {
"number": [
{
"value1": "={{ $json.rowCount }}",
"operation": "larger",
"value2": 0
}
]
}
},
"id": "if-rows-exist",
"name": "Has rows?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [680, 300]
},
{
"parameters": {
"jsCode": "// Normalize the brief into the state schema\nconst row = $input.first().json;\nreturn {\n json: {\n state: {\n brief: {\n targetKeyword: row['Target Keyword'],\n targetWordCount: parseInt(row['Word Count'], 10),\n audience: row['Audience'],\n requiredSections: row['Required Sections'].split('\\n').map(s => s.trim()).filter(Boolean),\n internalLinks: row['Internal Links'].split('\\n').map(s => s.trim()).filter(Boolean),\n styleGuideUrl: row['Style Guide URL'],\n briefId: row['Brief ID'],\n sheetRowId: row['__rowId']\n },\n draft: null,\n review: null,\n seo: null,\n published: null,\n revision: 1,\n modelUsed: 'openai',\n startedAt: new Date().toISOString()\n }\n }\n};"
},
"id": "code-init-state",
"name": "Init state",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 200]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-4o\",\n \"temperature\": 0.7,\n \"max_tokens\": 4000,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n { \"role\": \"system\", \"content\": \"You are a senior B2B content writer. Read the style guide at {{ $json.state.brief.styleGuideUrl }} before writing. Return a JSON object with: title, metaDescription, slug, bodyMarkdown, internalLinksUsed (array), externalSources (array), wordCount. No markdown code fences.\" },\n { \"role\": \"user\", \"content\": \"Brief:\\nTarget keyword: {{ $json.state.brief.targetKeyword }}\\nTarget word count: {{ $json.state.brief.targetWordCount }}\\nAudience: {{ $json.state.brief.audience }}\\nRequired sections: {{ $json.state.brief.requiredSections.join(', ') }}\\nInternal links to include: {{ $json.state.brief.internalLinks.join(', ') }}\\n\\nRevision: {{ $json.state.revision }}\\n{{ $json.state.review ? 'Previous review issues to address: ' + JSON.stringify($json.state.review.issues) : '' }}\" }\n ]\n}",
"options": {}
},
"id": "agent-writer",
"name": "Agent 1 — Writer (GPT-4o)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1120, 200]
},
{
"parameters": {
"jsCode": "// Parse the writer response, validate the JSON, store in state\nlet parsed;\ntry {\n const raw = $input.first().json.choices[0].message.content;\n parsed = JSON.parse(raw);\n} catch (e) {\n throw new Error('Writer returned invalid JSON: ' + e.message);\n}\nconst state = $('Init state').first().json.state;\nstate.draft = parsed;\nreturn { json: { state } };"
},
"id": "code-parse-draft",
"name": "Parse draft",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 200]
},
{
"parameters": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "anthropicApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "x-api-key", "value": "={{ $credentials.anthropicApi.apiKey }}" },
{ "name": "anthropic-version", "value": "2023-06-01" },
{ "name": "content-type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"claude-opus-4-5\",\n \"max_tokens\": 2000,\n \"messages\": [\n { \"role\": \"user\", \"content\": \"You are a senior editor. Review the draft against the brief. Return a JSON object with: overallScore (1-10), pass (boolean, true if score >= 7), issues (array of {severity, location, description, suggestedFix}), summary (one paragraph). No markdown code fences.\\n\\nBrief: \" + JSON.stringify($json.state.brief) + \"\\n\\nDraft: \" + $json.state.draft.bodyMarkdown }\n ]\n}",
"options": {}
},
"id": "agent-reviewer",
"name": "Agent 2 — Reviewer (Claude Opus)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1560, 200]
},
{
"parameters": {
"jsCode": "let parsed;\ntry {\n const raw = $input.first().json.content[0].text;\n parsed = JSON.parse(raw);\n} catch (e) {\n throw new Error('Reviewer returned invalid JSON: ' + e.message);\n}\nconst state = $('Parse draft').first().json.state;\nstate.review = parsed;\nreturn { json: { state } };"
},
"id": "code-parse-review",
"name": "Parse review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1780, 200]
},
{
"parameters": {
"conditions": {
"boolean": [
{ "value1": "={{ $json.state.review.pass }}", "value2": true }
]
}
},
"id": "if-review-passed",
"name": "Review passed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [2000, 200]
},
{
"parameters": {
"jsCode": "// Increment revision and loop back to the writer\nconst state = $json.state;\nif (state.revision >= 2) {\n // Out of retries — flag for human\n return { json: { state, action: 'needs-human' } };\n}\nstate.revision += 1;\nstate.modelUsed = state.modelUsed === 'openai' ? 'anthropic' : 'openai';\nreturn { json: { state, action: 'retry' } };\n"
},
"id": "code-retry-or-fail",
"name": "Retry or flag",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2220, 100]
},
{
"parameters": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "anthropicApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "x-api-key", "value": "={{ $credentials.anthropicApi.apiKey }}" },
{ "name": "anthropic-version", "value": "2023-06-01" },
{ "name": "content-type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"claude-sonnet-4-5\",\n \"max_tokens\": 1500,\n \"messages\": [\n { \"role\": \"user\", \"content\": \"You are an SEO analyst. Check this draft against the on-page SEO checklist. Return a JSON object with: score (0-100), checks (array of {name, passed, detail}), recommendations (array of strings). No markdown code fences.\\n\\nChecklist:\\n- Target keyword in title, H1, first 100 words, last 100 words, meta description, slug\\n- Keyword density 0.5% to 2.5%\\n- Word count within ±15% of target\\n- 3 to 8 H2s\\n- Every H2 contains a number, question, or power word\\n- At least 2 internal links, at least 1 external link to authoritative source\\n- Meta description 140-160 characters\\n- No paragraph over 4 sentences, no sentence over 35 words\\n- Every section answers search intent\\n\\nTarget keyword: {{ $json.state.brief.targetKeyword }}\\nTarget word count: {{ $json.state.brief.targetWordCount }}\\nDraft: {{ $json.state.draft.bodyMarkdown }}\" }\n ]\n}",
"options": {}
},
"id": "agent-seo",
"name": "Agent 3 — SEO checker (Claude Sonnet)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [2220, 320]
},
{
"parameters": {
"jsCode": "// Parse SEO response and write back to the sheet\nlet parsed;\ntry {\n const raw = $input.first().json.content[0].text;\n parsed = JSON.parse(raw);\n} catch (e) {\n throw new Error('SEO checker returned invalid JSON: ' + e.message);\n}\nconst state = $json.state;\nstate.seo = parsed;\nstate.published = {\n destination: 'notion',\n url: null,\n shippedAt: null\n};\nreturn { json: { state } };"
},
"id": "code-parse-seo",
"name": "Parse SEO",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2440, 320]
},
{
"parameters": {
"method": "POST",
"url": "https://api.notion.com/v1/pages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "notionApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Notion-Version", "value": "2022-06-28" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"parent\": { \"database_id\": \"YOUR_NOTION_DATABASE_ID\" },\n \"properties\": {\n \"Name\": { \"title\": [{ \"text\": { \"content\": \"{{ $json.state.draft.title }}\" } }] },\n \"Slug\": { \"rich_text\": [{ \"text\": { \"content\": \"{{ $json.state.draft.slug }}\" } }] },\n \"Status\": { \"select\": { \"name\": \"Draft\" } },\n \"Target Keyword\": { \"rich_text\": [{ \"text\": { \"content\": \"{{ $json.state.brief.targetKeyword }}\" } }] },\n \"SEO Score\": { \"number\": {{ $json.state.seo.score }} },\n \"Review Score\": { \"number\": {{ $json.state.review.overallScore }} }\n },\n \"children\": [\n { \"object\": \"block\", \"type\": \"paragraph\", \"paragraph\": { \"rich_text\": [{ \"type\": \"text\", \"text\": { \"content\": \"{{ $json.state.draft.bodyMarkdown }}\" } }] } }\n ]\n}",
"options": {}
},
"id": "agent-publisher",
"name": "Agent 4 — Publisher (Notion)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [2660, 320]
},
{
"parameters": {
"operation": "update",
"documentId": { "__rl": true, "value": "YOUR_GSHEET_ID", "mode": "id" },
"sheetName": { "__rl": true, "value": "Content Queue", "mode": "name" },
"column": "Status",
"value": "shipped",
"options": {}
},
"id": "gsheet-update",
"name": "Update sheet → shipped",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [2880, 320]
},
{
"parameters": {
"method": "POST",
"url": "https://slack.com/api/chat.postMessage",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"channel\": \"#content-shipped\",\n \"text\": \"Shipped: *{{ $json.state.draft.title }}*\\nReview: {{ $json.state.review.overallScore }}/10 · SEO: {{ $json.state.seo.score }}/100\\nNotion: {{ $json.state.published.url }}\"\n}",
"options": {}
},
"id": "slack-notify",
"name": "Slack — ship notification",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [3100, 320]
}
],
"connections": {
"Every 15 min": { "main": [[{ "node": "Read queued rows", "type": "main", "index": 0 }]] },
"Read queued rows": { "main": [[{ "node": "Has rows?", "type": "main", "index": 0 }]] },
"Has rows?": { "main": [[{ "node": "Init state", "type": "main", "index": 0 }], []] },
"Init state": { "main": [[{ "node": "Agent 1 — Writer (GPT-4o)", "type": "main", "index": 0 }]] },
"Agent 1 — Writer (GPT-4o)": { "main": [[{ "node": "Parse draft", "type": "main", "index": 0 }]] },
"Parse draft": { "main": [[{ "node": "Agent 2 — Reviewer (Claude Opus)", "type": "main", "index": 0 }]] },
"Agent 2 — Reviewer (Claude Opus)": { "main": [[{ "node": "Parse review", "type": "main", "index": 0 }]] },
"Parse review": { "main": [[{ "node": "Review passed?", "type": "main", "index": 0 }]] },
"Review passed?": {
"main": [
[{ "node": "Agent 3 — SEO checker (Claude Sonnet)", "type": "main", "index": 0 }],
[{ "node": "Retry or flag", "type": "main", "index": 0 }]
]
},
"Retry or flag": { "main": [[{ "node": "Agent 1 — Writer (GPT-4o)", "type": "main", "index": 0 }]] },
"Agent 3 — SEO checker (Claude Sonnet)": { "main": [[{ "node": "Parse SEO", "type": "main", "index": 0 }]] },
"Parse SEO": { "main": [[{ "node": "Agent 4 — Publisher (Notion)", "type": "main", "index": 0 }]] },
"Agent 4 — Publisher (Notion)": { "main": [[{ "node": "Update sheet → shipped", "type": "main", "index": 0 }]] },
"Update sheet → shipped": { "main": [[{ "node": "Slack — ship notification", "type": "main", "index": 0 }]] }
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [{ "name": "content-pipeline" }],
"active": false,
"versionId": "1.0.0"
}JSON 里几处没说清的地方:
- 重试循环用的是 n8n 的"回环"模式。
Retry or flag写回新状态,路由到写手。写手读$('Init state').first().json.state然后原地改,但合并回环是 n8n 在 Code 节点输出后自动做的。 Parse draft/Parse review/Parse SEO三个节点都是同一种模式:JSON 解析、存回状态、出错就抛。错误分支就是用来拦"模型返回带 Markdown 围栏的 JSON"或"返回空响应"这两种情况的。- Google Sheets 凭证在 read 和 update 之间共享。Notion、Slack、Anthropic、OpenAI 的凭证要预先在 n8n 凭证面板里配好,否则工作流跑不起来。
生产里真正坏过的环节
我用这个工作流(差不多就是上面这个形态)跑了 9 个月,差不多 600 篇文章。下面这些是真实坏过的。
**1. 模型有时把 JSON 包在 Markdown 围栏里。**就算你连说三遍"不要 Markdown 围栏",GPT-4o 偶尔还是会返回:
json```json
{"title": "...", ...}
解析器用一行 strip 来处理:
```javascript
const raw = $input.first().json.choices[0].message.content;
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
const parsed = JSON.parse(cleaned);为了让上面 JSON 看着不那么乱,这段我删了,但每个 parse 节点里都有。
**2. 审核有时打分太松。**我给 5 分的稿子,Claude Opus 能给 7 或 8。及格线定在 7 是因为平均分大概 6.5;抬到 8 会让误判变多。这个我还在盯——及格线写在提示词里,是最容易调的旋钮。
**3. 发布里 Notion 的 block 解析。**上面示例里,发布是把整段 bodyMarkdown 当成一个 paragraph block 发过去,Notion 会渲染成一坨字。实际跑的发布会把 Markdown 切分成 block(H2 → heading_2,段落 → paragraph,列表 → bulleted_list_item 等等)再发。为了不让文章太长,这部分我剪掉了。Markdown 转 Notion block 的逻辑是一个 60 行的 Code 节点——不复杂但丑。想要完整版,私我。
**4. 表格越堆越大。**每一行发完都留在表里。600 篇之后,读取还是快,但人眼扫起来慢了。我加了一条 =QUERY() 公式按状态过滤、90 天后归档。不是工作流的问题,但确实是运营问题。
**5. 审核会卡在风格指南的细节上。**如果风格指南写"不许用 em-dash"而写手用了一个,审核就会拒,哪怕文章其他地方写得不错。我在审核的提示词里加了一句软约束——"如果只是风格上的小毛病,不要拒"——有帮助。
**6. 成本会漂。**我按 0.55 美元/篇做预算。复杂的简报 3000 字目标能跑到 0.85 美元。重试一轮再加 0.40 美元。我见过的最坏情况:1.40 美元。最早 200 篇平均 0.38 美元;最近 100 篇平均 0.62 美元。漂移主要来自模型涨价和简报变长。我现在在简报里加了一个 costCeiling 字段——如果工作流的累计开销超过它,文章直接标给真人,停手。
**7. 发布不处理图片。**封面图是另一条流水线(就是封面图技能里写的那条)。发布只负责把图片 URL 从简报里拿出来、上传成 Notion file block。图片缺失就跳过——文章照发但带占位图。
怎么扩展
四个 Agent 的骨架稳定后,最直接的扩展是:
- **上游加一个关键词研究 Agent。**给一个主题,返回 5 个带意图标签的长尾变体,回写到简报。简报是空的、只有主题时特别有用。
- **加一个事实核查 Agent。**读初稿、抽取出所有数字或引文,跑一遍搜索 API(Tavily、Brave、Perplexity),把没法证实的标出来。这是我下一个想做的,也是最可能需要人参与的 Agent。
- **下游加一个社媒改编 Agent。**拿发完的稿子出 4 条 LinkedIn、4 条推特、1 段 newsletter 引子。输出是一组字符串,保存在同一行的
repurpose[]列里。 - **加一个预算守门员。**盯住累计 API 成本,超出每日上限就暂停。我做了这个——一个简单累加器,对照环境变量里的
dailyLimit。
流水线小到能塞一张画布,也小到一个人能维护。我已经三个月没动过上面的 JSON 了。提示词几周就要小修一次——写手提示词动得最频繁,审核提示词动得最少。
如果让我重来
如果今天从零开始搭,我会把队列从 Google 表格换成正经的队列系统。表格当看板可以——人眼能读、能改、能审——但表格不是队列。它没有行级锁,15 分钟的轮询节奏意味着,Anthropic 那边 16 分钟的 API 抖动就会让运行重复触发。一张 Postgres(一种流行的开源数据库)表,配上 SELECT ... FOR UPDATE SKIP LOCKED(一种数据库技巧,取下一行可用数据但不阻塞其他工作进程)才是该用的原语。n8n 有 Postgres 节点。代价是人不加个小后台就看不了队列。
我还会把提示词放进数据库,而不是工作流 JSON 里。现在我想把审核的及格线从 7 调到 8,得改工作流。一个工作流无所谓,但我后面还排着 3 个。建一张 prompts 表带版本历史,30 秒就能回滚一个改坏的版本,不用去翻 n8n 的执行日志。
不过那是对一条每周已经能跑 40 篇稿子的流水线的优化。文中的 JSON 是我正在用的版本,过去 8 个月没丢过一篇稿子。如果从零开始,直接用本文这段 JSON。架构撑得住,提示词需要你按自己品牌声音再调,表格在第 500 篇前后会变得笨重——提前规划,然后发货。
如果你搭起来了,又踩到了我没见过的失败模式,欢迎告诉我。真正有意思的失败,永远是那些我还没想到的。