上个月我终于把每天晚上雷打不动的“发文流程”给干掉了。以前一篇内容要走完“找选题—写初稿—改稿—配图—排版—发布”这条链,我手动点一遍至少 20 分钟,遇到平台抽风或者图片要重传,时间直接翻倍。后来我决定用 MCP 协议把内容平台的开放接口统一封装成标准工具,再让多 Agent 分工接力,搭出来一条自动化内容发布流水线。现在从选题到发布,我基本只负责偶尔扫一眼审核结果,剩下的事情机器替我跑完了。这篇复盘会把这一个月里踩过的坑、改过的设计、留下来的方案完整拆开,适合也在折腾内容自动化、对 MCP 和 Agent 编排感兴趣的朋友参考。
1. 为什么选 MCP 而不是把发布接口写死在脚本里
1.1 我先经历了“一堆脚本”的痛
在动手搭这套流水线之前,我已经有一版可以用的“伪自动化”脚本:一个抓素材的 Python 脚本、一个调模型接口写初稿的脚本、一个解析结果并调用内容平台开放接口发布的脚本。它们用命令行参数传数据,临时文件当中间产物。刚跑通那会确实爽,但用了一周问题就暴露得差不多了。
每次换模型要改脚本,每个平台的接口字段不一样也要改脚本,最头疼的是没人能讲清楚“现在到底跑到了哪一步”。脚本之间靠文件名约定通信,很容易出现上一个脚本没写完,下一个脚本就把空文件拿去发布了。我决定重写就是因为这个:自动化程度高不高是次要的,关键是每一步都得可控、可观测,而不是靠一串串孤立脚本碰运气。
1.2 MCP 到底解决了什么问题
MCP(Model Context Protocol)解决的是“模型怎么稳定地调用外部工具”这件事。你可以把它理解成给 AI 模型配一个“万能工具箱”:每个工具都有名字、参数说明、返回格式,模型需要某个能力时,自己去工具箱里找工具、填参数、拿结果。工具侧不需要为每个模型单独适配,模型侧也不需要为每个工具重新写一遍调用逻辑。
对我来说,最大的收获是把“发布平台能力”变成了独立组件。内容平台的开放接口、图片处理、任务通知,都被封装成一个个独立的 MCP Server。某个 Agent 想发文,只需要调用publish_article这个工具;哪天我换了一个模型,只要新模型也支持 MCP,我完全不需要改发布工具。这种解耦是脚本方案给不了的,也是我敢把流水线一直迭代下去的地基。
1.3 几个候选方案的对比
我也考虑过直接用“脚本加函数调用”,甚至纠结过要不要上一套重一点的 Agent 编排框架。最后选了 MCP + 轻量状态编排,核心原因可以看这张表。
| 方案 | 工具接入效率 | 换模型成本 | 可观测性 | 权限边界 | 我的评价 |
|---|---|---|---|---|---|
| 写死脚本调用 | 快 | 高,要改代码 | 一般,靠日志硬翻 | 弱,脚本里什么都能干 | 适合一次性任务,不适合流水线 |
| 自建 Agent 框架 | 中等 | 中等 | 看框架实现 | 中规中矩 | 偏重,配置成本高 |
| MCP Server + 任务表编排 | 初期要花时间封装工具 | 低,模型侧零改动 | 工具调用有完整记录 | 强,只暴露需要的工具 | 最稳,后续扩展最顺 |
选择 MCP 并不是因为它“新”,而是因为它把工具和模型彻底解耦了。我实际用的编排层其实特别简单,核心就是一张任务状态表加一个定时调度器,没有引入复杂的 Agent 通信协议——这套组合比想象中还要皮实。
2. 流水线整体骨架:六个 Agent 怎么接力
2.1 任务流转全貌
流水线从“灵感池”开始。灵感池是一张表,里面放着我想写的选题、抓到的素材链接、历史爆文的标题,也有不少是数据 Agent 回收回来的“内容摘要”。调度器每分钟扫一次任务表,发现有可执行的任务,就唤醒对应 Agent 去干活。整个流转过程是这样的:
new → ready → writing → written → reviewing → approved → publishing → published → analyzing → done
文章在“审核通过”之前的路径是:选题 Agent 从灵感池里挑候选并生成标题;写作 Agent 根据选题卡片写完整初稿;编辑 Agent 做文字校对、压缩、摘要和关键词抽取;审核 Agent 做事实和合规审查。审核通过后交给发布 Agent,它会先把内容格式化成平台要求的 HTML、配图,再调用发布工具提交。发布 24 小时后,数据 Agent 拉取表现数据,把核心结论写回灵感池,整个闭环才算完成。
2.2 每个 Agent 的职责边界
我设计了六个角色,但每个角色的输出都必须是结构化 JSON,不允许丢一堆自然语言让下游自己猜。以写作 Agent 为例,它的输出长这样:
{ "title": "正文标题", "content_md": "正文 Markdown 全文", "abstract": "一句话摘要", "keywords": ["关键词一", "关键词二"] }下游的编辑、审核、发布都能直接解析这些字段,不需要再做信息抽取。我对每个 Agent 的职责边界卡得很死:
- 选题 Agent:只负责从灵感池中筛选和生成标题候选,不写正文。
- 写作 Agent:按选题卡片生成初稿,不负责发布格式。
- 编辑 Agent:负责校对、压缩、抽取摘要和关键词,不改事实性数据。
- 审核 Agent:负责合规和事实核查,只输出通过或打回意见。
- 发布 Agent:格式化内容、上传配图、调用 MCP 发布工具,不写内容。
- 数据 Agent:拉取数据、生成复盘摘要,写回灵感池。
Agent 之间的协作不是“自由对话”,而是“谁干完活,就把结果写进任务表;下一个按状态机拿走继续”。这种模式最大的好处是定位问题容易——任务卡在哪个状态,就是对应环节的问题,不用去猜是哪句话把 Agent 带偏了。
2.3 为什么不让 Agent 互相自由交流
我特意把协作方式设计成“同一时刻只有一个 Agent 干活”,原因是多 Agent 自由对话场景里,几个 Agent 同时改同一份文档很容易互相覆盖,而且上下文一拉长,token 消耗涨得飞快。接力棒模式虽然朴素,但稳定、便宜、好排查。后面如果想并行,我会上真正的并行子任务拆分,而不是让 Agent 挤在一个任务里各说各话。
3. 把内容平台封装成 MCP Server 的核心实操
3.1 工具清单
发布环节的核心,是把内容平台的开放接口封装成 MCP Server。我暴露的工具不多,一共六个,刻意保持“够用就好”。
| 工具名 | 作用 | 关键参数 | 权限说明 |
|---|---|---|---|
| get_category_list | 获取栏目和分类列表 | 无 | 只读 |
| create_draft | 在平台创建草稿 | title, content_md, cover_url, tags | 写入草稿,不发布 |
| submit_article | 提交发布或定时发布 | article_id, publish_time, client_request_id | 写操作,有幂等保护 |
| update_article | 更新已有文章 | article_id, title, content_md | 写操作 |
| fetch_metrics | 拉取文章数据 | article_id, date_range | 只读 |
| send_notify | 发送一条消息给维护者 | message | 特殊 |
删除文章、修改账号设置这类接口一律没有放进 MCP Server。这样哪怕模型被诱导,或者某个 Agent 突然幻觉发作,它能造成的破坏也有限。工具白名单本身就是一条权限边界,这是我强烈建议照做的设计。
3.2 鉴权:模型永远看不到密钥
平台侧的 access_token 只存在 MCP Server 的环境变量里,由工具内部自行读取,模型能看到的只有业务参数:文章 ID、标题、正文。另外 token 每两小时过期,我在 MCP Server 里实现了自动刷新机制——只要发现错误码是TOKEN_EXPIRED,就静默刷新一次并重发请求,对这个 Agent 来说完全透明。
这个设计看起来很简单,但很关键。如果让模型直接拿着长凭证去调平台,一旦它在某个非发布场景里不小心把凭证打印出来,问题就大了。把鉴权下沉到工具层,整个流水线才敢让模型放开手脚干活。
3.3 统一返回结构与错误码
所有工具统一返回下面这种结构:
{ "success": true, "data": { "article_id": "123456" }, "error": null }错误时success为 false,error里带上固定错误码和人类可读的 message。错误码我定成了四个:RATE_LIMITED、TOKEN_EXPIRED、CONTENT_REJECTED、VALIDATION_ERROR。错误信息必须写得像人话,比如CONTENT_REJECTED后面一定会跟原因,因为审核 Agent 需要根据这个原因去修改正文。
代码层面,一个发布工具大体长这样:
@server.tool() def submit_article(article_id: str, publish_time: str = "", client_request_id: str = "") -> dict: token = get_valid_token() resp = platform_api.post("/articles/submit", token=token, json={ "article_id": article_id, "publish_time": publish_time, "client_request_id": client_request_id }) if resp.status_code == 429: return { "success": False, "data": None, "error": { "code": "RATE_LIMITED", "message": "请求太频繁,建议 60 秒后重试" } } # 其余状态码同理处理这套返回结构让模型很容易判断下一步该干什么:看见RATE_LIMITED就等一会儿,看见CONTENT_REJECTED就把正文调整后再来一次。
3.4 幂等参数为什么是保命设计
发布不是查询,写操作一旦重试就可能产生脏数据。我在submit_article里强加了client_request_id,流水线每次发布都生成一个 UUID 并透传过去,平台侧(或 MCP Server 内部缓存)对同一个 request_id 只处理一次。这个细节刚开始我没有做,后来付出了一篇重复文章的代价,具体事故见第 5 章。现在幂等逻辑已经成为流水线的默认前置条件,没有幂等保护的写工具,我不会放给 Agent 用。
4. 多 Agent 协作的调度与状态机细节
4.1 一张任务表搞定协作
Agent 之间没有握手协议,真正的“协作中心”是 SQLite 里的一张 task 表。主要字段有:task_id、topic、current_agent、status、input_json、output_json、attempt_count、error_msg、created_at、updated_at、deadline。
调度器每分钟查一次,规则只有一条:如果任务的当前状态有对应的待执行 Agent,并且 updated_at 超过 30 秒没有再更新,就把它标记为 running 并唤醒对应的 Agent。这其实是一个简单租约机制:状态更新操作会把旧的 updated_at 带进 WHERE 条件里,防止两个调度进程同时处理同一个任务。
4.2 状态机与乐观锁
状态迁移不允许乱跳。我在代码里维护了一张迁移表,写作状态只能走向“已写完”,已写完只能走向“审核中”,审核中只能走向“通过”或“打回”。更新任务时必须带上预期状态作为条件:
UPDATE task SET status = 'written', output_json = ?, current_agent = 'editor', updated_at = ? WHERE task_id = ? AND status = 'writing'如果影响行数是 0,说明状态已经被别的进程改了,这一次写入直接视为失败并回滚。这是非常朴素的乐观锁,但足够可靠。状态迁移表用 Python 字典维护也很直观:
TRANSITIONS = { "writing": {"written"}, "written": {"reviewing"}, "reviewing": {"approved", "rejected"}, "approved": {"publishing"}, "publishing": {"published", "retrying"}, "retrying": {"publishing", "dead"}, }代码里再写一个can_transit(from_state, to_state)的判断函数,所有 Agent 更新状态前都必须过这一关。这套机制保证任何 Agent 都没办法跳过审核直接发布。
4.3 上下文传递和 token 预算
每个 Agent 的输入不是“整篇文章全部塞进去”,而是读取上一环 output_json 里的必要字段。写作 Agent 拿的是选题卡片和素材摘要;编辑 Agent 拿的是正文和当前标题、摘要。每个 Agent 的上下文都有明确边界,不会无限膨胀。
我还给输出做了长度预算:比如审核 Agent 的 review_comment 最多 500 字,避免一个 Agent 把冗余内容灌给下一个。这一步纯粹是成本控制,实测对 token 消耗影响非常大,而且强制压长度反而倒逼 Agent 输出更核心的信息。
4.4 失败重试与人工兜底
每个 Agent 最多重试 3 次。重试时我不会让同一个 Agent 用完全相同的 prompt 再跑一遍,而是把上一次的错误信息追加进去,或者调高输出的随机性,避免同一个错误反复出现。3 次还失败,任务进入dead状态,同时调用send_notify工具给我发一条消息提醒。
我另外加了一个兜底规则:任何任务 24 小时没推进,也会触发提醒。毕竟“全自动”不等于没人管,尤其是审核边界不太清晰的新内容类型,关键时刻还是要人工介入看一眼。
5. 实测中踩过的坑:从内容翻车到重复发文
5.1 审核意见没有传回写作 Agent
第一次跑通全流程后,我发现一个问题:审核 Agent 经常打回文章,但写作 Agent 完全不知道原因。排查后定位到原因,是 output_json 里只存了approved: false,没存意见。
修复非常简单:强行要求审核 Agent 输出review_comment字段,并且在状态变为rejected时,把这个意见注入到下一次写作 Agent 的输入里,包括标题修订建议、分类修改建议、正文修改建议,最多三条。改完之后,文章基本一次打回就能改到点子上,二次打回的概率大幅下降。
5.2 超时重试导致同一篇文章发布两次(完整排查链路)
这个坑最疼。有天早上打开平台后台,发现同一篇内容出现了两个版本,发布时间只差 40 秒。我第一反应是平台接口 bug,翻日志才发现根本不是。完整排查链路是这样的:
- 先查任务表的 publishing 状态,发现这个任务 attempt_count = 2。
- 打开 MCP Server 的调用日志,看到第一次
submit_article请求已经发出,响应是 success。 - 但客户端这边因为网络超时没有收到响应,于是触发了重试逻辑。
- 重试请求带着同一个 article_id 到平台,平台又创建了一次发布,于是变成两篇文章。
- 根因是没有任何东西告诉平台“这两个请求其实是同一个操作”。
修复就是前面说的client_request_id。发布工具要求流水线每次为任务生成唯一 ID 并透传,平台侧记录已处理过的 request_id,相同 ID 直接返回第一次的结果,不再重复创建。这是写操作接口必须有的设计,我第一版偷懒没做,结果踩了大坑。从那以后,幂等键成了我所有 MCP 写工具的基础设施。
5.3 图片外链第二天全部失效
还有一次排版没问题,但发布完第二天,用户打开文章发现图片全裂了。排查发现是配图 Agent 把图片传到了平台的临时上传接口,拿到的 URL 有效期只有 24 小时。
修复方案是加一个自建的文件交付服务,生成的图片先传到自建静态资源服务,拿到永久地址后再放进正文。如果自建成本嫌高,退一步也至少要传到平台的素材库,引用素材的永久 ID,坚决不能用临时直链。这个问题藏得很深,因为发布当天预览完全是正常的,只有等第二天才暴露。
5.4 模型幻觉给内容安错分类和标签
模型写出来的文章,内容明明是新闻快讯,分类却被归进“教程”,标签也是随手生成的。这件事看起来不起眼,但非常影响文章检索和后续数据统计。
我的修复方案是在审核 Agent 里加了一个“白名单校验”:先把平台的分类和标签候选拉出来,模型只能从候选里选,选错就返回VALIDATION_ERROR,并且把候选列表一起放进错误信息里。人的工作从“逐篇改标签”变成“维护白名单模板”,可靠得多。
5.5 集中发布触发限流
第一次批量上线 10 篇时,调度器在同一分钟全部提交,平台很干脆地返回了 429。当时我以为“只要工具层报了错,系统自己会处理”,结果系统确实在处理,但处理方式是所有任务一起重试,场面更乱了。
修复分两步:第一步,调度策略改成每分钟最多提交 2 篇,天然错峰;第二步,对RATE_LIMITED错误做指数退避,第一次等 30 秒,第二次 90 秒,第三次 300 秒。自从加了这两个策略,限流再也没有成为阻塞项。
5.6 踩坑清单汇总
| 问题 | 根因 | 修复方案 |
|---|---|---|
| 审核意见弄丢 | 输出只存布尔结果 | 强制结构化 review_comment 字段 |
| 重复发文两篇 | 超时重试无幂等保护 | 全局 client_request_id |
| 第二天图片失效 | 临时 URL 有有效期 | 自建文件服务出永久链接 |
| 分类标签乱 | 模型自由发挥 | 平台候选白名单校验 |
| 集中发布限流 | 没有节流策略 | 每分钟 2 篇 + 指数退避 |
6. 跑了一个月之后的变化和下一步尝试
6.1 现在流水线跑成什么样了
运行了大约四周,自动处理了 30 多篇内容,成功发布 22 篇,打回重写 8 次,人工介入 3 次。整体数据大致是这样的:
| 指标 | 数据 |
|---|---|
| 单篇平均生产耗时 | 约 8 分钟 |
| 单篇平均重试次数 | 0.7 次 |
| 人工介入率 | 10% 左右 |
| 发布后阅读表现 | 与人工编辑基本持平 |
对个人内容更新频率来说,这套投入已经值回票价。最大的收益反而不是省时间,而是每个环节都变成可测试、可重放、可定位的模块。文章发布后出了任何问题,我能快速锁定是哪个环节的问题,而不是对着一个黑盒脚本发呆。
6.2 半自动起步的真实建议
如果只想复制这套思路,我的建议是从半自动开始。第一个版本别追求“一键全文发”,让每个 Agent 干完活先停在那里,你检查一遍再放行;连续两周没出质量问题后,再把发布 Agent 的自动开关打开。另外,审核 Agent 和幂等机制必须排在自动化发布之前做好,这两件事没到位,全自动就等于定时炸弹。
最后分享一个我一直觉得很重要的小细节:我把整个流水线的运行记录统一写进一张审计表,谁在什么时间调用了什么工具、返回了什么错误,全部留痕。这张表几乎不占地方,但排错时有奇效,尤其是某个模型输出不稳定的时候,你能一眼看出它是不是连续调错了几次参数。这套流水线之后如果要加新平台、新内容类型,我也会继续沿着“工具封装 + 状态机 + 白名单 + 审计”这套底座来扩展,不会轻易换成更重的框架。