最初的需求很简单:每天早上抓一批 RSS,过滤一遍,发到飞书群。后来一路改下来,变成了一套支持多源、多过滤器、多调度任务、多 AI 渠道、跨 VM 协程并发的装配式系统。这篇记录这条路上的几个关键转折点。

起点:一份 YAML 工作流

需求的雏形是一份 YAML:14 个 RSS 源、关键词粗筛、LLM 聚类评分、多源初稿、写飞书文档、推送通知。逻辑都在一个 workflow 文件里串起来。

这种写法的好处是声明清楚,问题也清楚——改动门槛太高。新增一个源要改 YAML,调整关键词要改 plugin,调度时间要改 cron,所有改动都要走"改文件 → 重启服务"的循环。RSS 源每天都在变,黑名单也在变,这种循环让节奏拖得很长。

我想要的是另一种节奏:改一个 lua 文件,立刻生效

第一步:每个源一个文件

把 14 个源拆成 14 个独立 lua 文件,每个文件返回一份元数据:

-- scripts/rss/sources/hackernews.lua
return {
    name = "hackernews",
    url = "https://hnrss.org/best",
    category = "overseas",
    enabled = true,
    timeout = 30,
}

管线声明只列名字,不重复声明源的细节:

-- scripts/rss/pipelines/default.lua
return {
    sources = { "hackernews", "techcrunch", "openai_blog", ... },
    filters = {
        { name = "exclude_keywords" },
        { name = "dedup" },
        { name = "relevant_keywords" },
        { name = "source_diversity", ctx = { max_items = 200 } },
    },
}

过滤器同样一个文件一个,每个文件返回 { meta, apply(items, ctx) }apply 拿到当前 items 列表,过滤后返回新列表。链式调用,前一个 filter 的输出是后一个 filter 的输入。

这一步的关键是约定大于配置:目录结构本身就是路由。rss/sources/x.lua 即是源 x 的全部声明,rss/filters/x.lua 即是过滤器 x 的全部实现。新增一个源,只需要新建一个文件、把名字加到 pipeline 数组里。不需要改 Go 代码,不需要重启容器。

drawing-1781505820670

第二步:调度也装配化

到这一步装配式只覆盖了 RSS 内部。调度还是硬编码:Go 侧 cronScheduler.AddFunc("0 0 * * *", ...) 写死两条任务。新增一个定时任务又要改 Go 代码、重新编译、重启容器

把这个也改成装配式:每个定时任务一个文件,结构与命令脚本同构。

-- scripts/schedules/rss_collect.lua
return {
    meta = {
        name = "rss_collect",
        cron = "0 0 * * *",
        description = "每天 UTC 00:00 抓取并过滤 RSS",
        enabled = true,
    },
    handle = function()
        local rss = dofile(SCRIPT_DIR .. "/lib/rss/core.lua")
        rss.run_collect("default")
    end,
}

Go 侧实现一个 Scheduler,启动时扫描 scripts/schedules/*.lua,按 cron 注册。但这里有个新问题:Lua 是无状态的,Go 怎么知道有新文件加入了

两个机制配合:

  • 周期重扫描:每 5 分钟扫一次目录,签名变化时重建 cron。签名是 sha256(filename + name + cron + enabled + description) 的有序拼接。没变化就什么都不做,避免日志刷屏。

  • 手动 reload:暴露 scheduler.reload() 给 Lua,命令脚本里调用即可立即生效。

drawing-1781505839819

签名比对这一步不能省。最初没做,每 5 分钟就在日志里输出一遍"已注册 N 个任务",看着像出问题了。后来改成只在签名变化时才输出,安静多了。

第三步:跨 VM 协程

抓取 78 个 RSS 源,串行跑下来要好几分钟。装配式架构需要并发,但这里有个现实约束——gopher-lua 的 LState 不是 goroutine-safe 的。同一个 VM 里开 goroutine 调 Lua 函数会崩。

解决方法是每个并发任务一个独立 LState。Go 侧暴露 async.parallel

local results = async.parallel({
    { code = "...", args = {...} },
    { code = "...", args = {...} },
}, { concurrency = 8, timeout = 60 })

code 是 Lua 源码字符串,不是函数。原因是 Lua 函数闭包绑在具体 LState 上,跨 VM 不能传。源码字符串可以在新 VM 中重新加载,args 通过 goToLua / luaToGo 深拷贝过去。

每个子 VM 通过 bindings.RegisterAll 自动获得全部模块(http / json / log / feishu / rss / ai 等),子任务里可以正常 require("http")。worker goroutine 里跑完一个 task,结果写入 results[i],主 goroutine 用 WaitGroup 收。

![[跨VM协程模型.relationship.excalidraw]]

抓取改造后,78 个源的总耗时从串行的几分钟降到并发后的几十秒。LLM 分类、版块导语生成都按同样模式跑。

第四步:多模型多渠道

LLM 的调用有两类:

  • 分类、聚合、短文本 → 量大、对延迟敏感、模型可以小一点

  • 写文章、生成卡片摘要、复杂推理 → 量小、对质量敏感、需要主力模型

ai 模块改造成多渠道:

ai:
  default: main
  channels:
    main:
      base_url: ...
      api_key: ...
      model: claude-opus-4-7
    summary:
      base_url: ...
      api_key: ...
      model: claude-haiku-4-5-20251001

Lua 侧调用区分两类:

ai.chat(prompt)              -- 用默认渠道
ai.chat(prompt, "summary")   -- 短文本批量任务
ai.chat(prompt, "main")      -- 主力推理

并发分类一次跑 60 条,全走 summary 渠道。文档报头导语、各版块导语用 main 渠道,并发 4 个版块同时生成。这样既不会让小模型抢主推理的额度,也不会让主模型为短任务买单。

第五步:报纸版式

文档生成最初是平铺:标题 / 摘要 / 链接,按照入选顺序排下来。打开看就是一长串列表,分不出主次。

改成报纸版式:

  • 报头 H1:日期 + 时间戳

  • 抓取统计:注脚一行

  • 开篇导语:80-120 字(main 模型)

  • 各版块:H2 版块名 → 版块导语(main 模型,30-50 字)→ 条目列表

  • 每条目:H3 标题(粗体不带链接)→ 元信息(斜体)→ 摘要 → 「🔗 阅读原文」段

版块由 M.SECTIONS 数据驱动:

M.SECTIONS = {
    { id = "headline",   name = "🗞️ 头版要闻",   categories = {"world", "geopolitics"} },
    { id = "cn_news",    name = "🇨🇳 中国要闻",   categories = {"cn_news"} },
    { id = "cn_policy",  name = "🏛️ 政策 · 时政", categories = {"cn_policy", "politics"} },
    { id = "tech",       name = "🤖 科技 · AI",   categories = {"tech"} },
    -- ...
}

每个 source 的 category 决定它属于哪个版块。新增一个版块只是往这个数组里加一项。同样的装配式思路。

drawing-1781505867821

第六步:消息分发不阻塞

测试时发现一个严重问题:构建命令在跑的时候,发其他消息没响应

排查发现飞书 SDK 的 OnP2MessageReceiveV1 回调虽然立刻返回,但子流程没用独立 goroutine。改成每条消息一个独立 goroutine + 独立 LState,互不干扰。再加上 defer recover(),子流程 panic 不影响后续消息。

还有一个相关的问题——同群外的消息被默认 drop。我用了一个白名单字段:

feishu:
  chat_id: "oc_xxx"                    # 主群
  allowed_chats:
    - "oc_yyy"                         # 额外放行的群(如 RSS 日报群)

onMessage 检查 chat_id 是否在 chat_id ∪ allowed_chats 中。命令本身可以再用 meta.chats 做更细的群聊白名单:

return {
    meta = {
        name = "news",
        aliases = { "要闻", "获取要闻" },
        chats = { "oc_yyy" },     -- 仅 RSS 群可用
        ...
    },
}

非白名单群里发"要闻"会被命令路由跳过,fallback 到 on_message.lua,相当于隐式忽略。

几个踩到的坑

轻量 VM 读 meta 时 require 报错:调度器扫描 schedules/*.lua 时用一个轻量 VM 只读 meta 字段。但脚本顶部的 require("log") 触发了 base 库的 require,需要 package 库才能解析,但轻量 VM 没开。解决方法是注入 stub require / dofile,返回空表,让顶部依赖加载短路。元数据是字面量,不依赖运行时模块。

XML 解析的 fallback 误读:最初 RSS 解析放在 Lua 侧,正则匹配 item / entry。遇到反爬页面或重定向后的 HTML,正则也能匹配上一些 <...> 标签,输出乱七八糟的 22 条无效数据。改用 Go 的 encoding/xml + 严格模式关闭 + 优先 RSS 2.0 后回退 Atom,鲁棒性立刻好了一截。

UTF-8 字节截断切坏中文:摘要超过 600 字节时用 s:sub(1, 600) 截断,遇到 UTF-8 多字节字符切到一半就乱码。补一个 utf8_safe_sub,从字节位置回退到最近的字符起始字节(首字节 < 0x80>= 0xC0)。

翻译占位符泄漏:让 LLM 按 TITLE: <最终标题> 格式输出,模型偶尔会原样返回 <最终标题>。检测响应里残留的尖括号占位符,视为格式失败保留原文。

配置 key 单复数差异permissions.admin vs permissions.admins,单复数配错谁都看不出来,权限拒绝又是 silent_deny,用户体验是"我明明在列表里却没权限"。最后加成两种 key 都接受。

最终架构

整套系统的边界长这样:

drawing-1781505889109
  • Go 是基座:模块绑定、消息路由、命令路由、调度器、并发模型、HTTP 客户端、SDK 封装

  • Lua 是业务:所有 source / filter / pipeline / schedule / command 都是 lua 文件,热更新

  • 两者通过 bindings.RegisterAll 接合,每次事件触发新建一个 LState,注入完整模块

实际数据流:

drawing-1781505923655

几条复盘

把工作流系统改成装配式系统,关键不是把代码切碎,而是让目录结构本身成为路由。文件名、目录名、文件返回的 meta 字段,三者构成自描述的接口。新增一个东西,只需要往对应目录里放一个文件。

并发要落到位需要承认基础设施的边界。gopher-lua 一个 VM 不能 goroutine 安全,那就开多 VM。源码字符串不是函数闭包那么自然,但跨 VM 是它能跨的代价。承认这一层,并发就能稳。

LLM 多渠道的本质是让贵的资源做贵的事。分类、翻译、聚合走 summary,写作走 main,配额会健康很多。

最后一条:把可见文案和内部标识符分开。日志里继续叫 RSS,用户卡片里全换成"日报""新闻快讯"。运维按 RSS 搜日志,用户看到的是友好术语,两边都不打架。