AI Tools

n8n 多 Agent 内容流水线:写手 → 审核 → SEO 检查 → 发布(完整工作流 JSON)

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。其他的要么挑刺,要么搬运文字。

提示词分四段:

  1. 角色——"你是一位服务于 [品牌] 的高级 B2B(business-to-business,企业对企业)内容写手,写作风格以 [URL] 风格指南为准。"
  2. 输入——表格里的简报(关键词、字数、章节、链接)。
  3. 约束——白纸黑字:H2 不许用反问开场、不许写"在这个快节奏的时代"这种开头、不许编造统计数据、行内给引用来源、结尾的 CTA(Call to Action,行动号召,告诉读者下一步做什么)必须符合页面模板。
  4. 输出契约——一个 JSON 对象,字段为 titlemetaDescriptionslugbodyMarkdowninternalLinksUsed[]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:审核

审核的活儿是读初稿、出结构化审稿意见。它不重写,只挑问题。

提示词分三段:

  1. 角色——"你是 [品牌] 的高级编辑,有 15 年编辑经验。严厉但公平。"
  2. 输入——简报、风格指南、写手的初稿。
  3. 输出契约——一个 JSON 对象,字段为 overallScore(1–10)、pass(布尔值,≥ 7 即通过)、issues[](每条 issue 含 severitylocationdescriptionsuggestedFix)、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,按模式匹配文本的工具)和模板匹配。

提示词分三段:

  1. 角色——"你是一名 SEO 分析师,按一份固定的页内 SEO 清单给页面打分。"
  2. 输入——简报(目标关键词、目标字数、必备章节)、初稿。
  3. 输出契约——一个 JSON 对象,字段为 score(0–100)、checks[](每条 check 含 namepasseddetail)、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% 之间"这种布尔数学它做不好。让确定性的代码去处理。

工作流接着把 scorerecommendations[] 回写到表格那一行。如果分数低于 70,文章在表格里被打上 seo-needs-work 的标记。文章照发,但人在队列里能看到这个旗标。我没遇到过一篇低于 70 又不需要真人动手的稿子——到这个分,简报通常就错了,不是稿件的事。

Agent 4:发布

发布是最"笨"的 Agent,也是我最信任的。它把审核通过的稿件格式化到目标 CMS(Content Management System,文章实际住的后台),然后推过去。

对接 Notion 的话,发布会:

  1. 在数据库里新建一个 page,含 titleslugstatus: "draft"(在 Notion 里还是草稿——人最后按发布)。
  2. 上传封面图(由另一条封面图流水线单独生成)。
  3. 把正文按 Notion block 切分插入(paragraph、heading_2、heading_3、bulleted_list_item、code、image、quote——这些是 Notion block 类型)。
  4. 写入 SEO ScoreReview ScoreTarget Keyword 三个属性。
  5. #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 篇前后会变得笨重——提前规划,然后发货。

如果你搭起来了,又踩到了我没见过的失败模式,欢迎告诉我。真正有意思的失败,永远是那些我还没想到的。