← blog 技术原理与实践 · 2026-08-06

Mini-agent 的博客发布链路:从会话隔离到 Fuwari Schema 校验

Mini-agent 把查询结果自动沉淀为博客时,先后处理了沙箱写盘失效、跨租户话题泄漏、重复文章和 front matter 类型错误。本文按发布链路重组这些改造与边界。

15 min read · updated 2026-08-07

mini-agent 会把一次有技术价值的查询,或一段已经结束的话题,整理成博客并推送到站点。最初这像是一个“让 LLM 写 Markdown”的功能,后来才发现真正难的部分不在正文:谁可以写博客、文章的输入属于谁、重复内容如何处理,以及 LLM 输出能否通过 Fuwari 的类型检查。

这篇文章把几次改造重新按发布链路组织。它不再按提交时间追加“后续修复”,而是从一段问答进入系统,到一篇文章被推送到 Git 的顺序说明现在的实现。

发布链路原来有两条线

查询和任务的结果仍会写入按天轮转的 result-YYYY-MM-DD.log。tasks/code_task.py 使用 [query_start]、[query_topic]、[query_end]、[query] 和 [task] 五种标签记录事件。它们用于审计和排查;单次查询用 [query],话题内的问答用 [query_topic]。

博客生成和 Git 同步是两条独立的链:

单次查询 / 已结束的话题
        |
        v
ResultBlogActor -> BlogUpdateActor -> 原子写入 .md
                                            |
                                            v
                                BlogSyncTask 监听文件变化
                                            |
                                            v
                         git add -> commit -> pull --rebase -> push

旧实现的问题在于,它把结果日志同时当成了话题内容的数据库。话题开始时记录日志字节 offset,结束时从 offset 向后扫描标签,再拼出一篇文章。这种设计把“话题是否还开着”“调用者是谁”“日志从哪里开始读”分散到了线程名、多个 dict 和一份共享日志里。后面的漏洞和重构都从这里开始。

Pi 不应该拥有博客仓库的写权限

最早的方案是:话题结束后由 CodeTaskExecutor.execute() 调用沙箱里的 Pi,让 Pi 执行 /blog-update --yes <topic>,生成并写入文章。Pi 在 bubblewrap 里运行,环境变量走白名单,文件系统按 ro/rw 路径绑定;于是 043bba1 曾尝试把整个博客仓库 rw-bind 给它。

这个改动没有真正生效。PiRpcClient._build_bwrap_argv() 只要收到 per-user workspace,就把可写集合组装为 workspace 和当前工作目录,传进来的 rw_paths 被忽略:

tenant_mode = user_workspace is not None
rw_path_set = {
    p for p in ([cwd, user_workspace] if tenant_mode
                else [cwd, project_dir] + list(rw_paths or []))
    if p
}

pi_workspace 有默认路径,连单用户调用也会得到 workspace。因此博客仓库始终是只读绑定,rw-bind 不是偶尔失效,而是在所有实际路径上都是死代码。c893a36 和后续清理把它删掉。

写盘随即移回 agent 进程。现在 Pi 可以把博客仓库当只读上下文,但不能写入或推送;ResultBlogActor 与 BlogUpdateActor 在 agent 进程使用主聊天 LLM 完成生成、判断和落盘。写博客不再需要给多租户 Pi 开一个例外权限。

这次调整也保留了一个有用的同步边界:BlogSyncTask 的本地 git add/git commit 失败不重试,因为这通常是作者信息、冲突或工作树问题;网络阶段的 git pull --rebase/git push 才按默认 600 秒、最多 3 次重试。同步中又有文件变化时,任务结束后再跑一次,而不是并发执行两组 Git 命令。

从日志扫描到显式 TopicSession

写盘移回 agent 进程后,旧的日志扫描终于真的能写出文章,也暴露了跨租户漏洞。

旧的 _end_topic 用下面的条件判断是否正在话题中:

thread_id != "default"

这在单用户模式大致成立,但多租户默认线程名是 u<id>_web_<token>,永远不等于 "default"。于是一个不在话题里的用户发送 /query_end,仍会以 log_offset=0 调用旧的 handle_topic(topic_name, log_offset)。

旧的 _collect_topic_entries() 会在当天共享 result-*.log 中寻找结束标签,再收集之前的 [query_topic] 条目。它没有 owner 过滤;过去存在的 re.sub(r"^u\d+\s+", "", title) 只是在标题上去掉租户前缀,并没有隔离内容。完整触发链是:租户 A 的一次误触 /query_end,扫描并吸收租户 B、C 的话题问答,写成 Markdown,再被 BlogSyncTask 推送到公开博客。旧的 query_end 还缺少其兄弟命令已有的 admin 守卫。

问题在 Pi 写不进博客时没有爆出来,只是被一个无关的失败掩盖了。把写盘迁到进程内并不是制造漏洞,而是让原本不可达的路径真正执行。

修复没有继续给线程名比较加条件,而是引入 core/topic_session.py:

TopicSession(topic, thread_id, owner, turns)

TopicSessionStore 以调用者 key 管理这个对象:认证用户用 user_id,单用户使用 chat/token 派生 key。它只有 start、get、is_open、record、end、discard 和 retain 这些操作。

agent.py 只创建一个 store,同时交给 MessageHandler 和 WebChannel。这解决了旧实现里的三组重复状态:WebChannel 的 _active_threads/_topic_log_offsets,MessageHandler 的 _web_threads/_topic_log_offsets,以及两边不一致的 topic thread 命名。无论由 /topic/start 还是 /message "/query_start" 打开,都进入同一个 session。

现在有三个直接可检查的约束:

  • store.is_open(key) 是唯一的话题判断,不再比较线程名。
  • end() 在没有打开话题时返回 None,误触 /query_end 不会发布任何内容。
  • 文章只读取 session 自己的 turns,不再从共享结果日志反推内容。

旧的 _collect_topic_entries、ENTRY_SPLIT 正则、标题前缀清理和 byte offset 状态都被删除。结果日志仍保留,但它不再决定一篇文章包含谁的问答;也因此此前“日志格式会影响话题解析”的约束消失了。

异步响应和长话题也有归属问题

话题对象化之后还有两个边界要补。查询发出后,用户可能先结束旧话题,或立即切换到新话题;如果 Pi 返回时才按 caller key 查询当前 session,迟到的回答会进入新话题,或者被直接丢掉。

现在 Telegram、Web JSON 和 Web SSE 路径都在等待 Pi 之前捕获当前 TopicSession 及其 thread_id。返回后直接写回这个对象。旧话题已经结束并不改变这次响应最初的归属。

每个 session 最多保存 200 轮。到达上限后不会再拒绝新问答,而是淘汰最早的一轮,保留最近的 200 轮。这是内存上限,也是博客的内容边界:超长话题不会无限增长,文章优先使用近期讨论。

对应回归测试覆盖了两个真实用户共用 HTTP 服务时的 /query_end 隔离、话题内外的发布差异、会话切换期间的迟到回答,以及第 201 轮加入后最早记录被淘汰的行为。

单次查询和话题走同一个 BlogUpdateActor

会话隔离解决的是“哪些内容可以成为文章”,但没有解决“是否需要新文章”。AI 日报一类固定主题在早晚用不同说法查询,很容易得到两篇语义重复的文章。

6aa2a4c 将这部分集中到 tasks/blog_update.py 的 BlogUpdateActor。ResultBlogActor 保留原有外部接口:handle_single(question, output) 和 handle_topic(session),但只把输入转为统一的 BlogSource;扫描、决策、生成和写入全部进入同一个 actor。

actor 每次写前读取博客目录中的 Markdown front matter,取得标题、描述、标签、分类、发布日期和正文摘要。它用英文词与中文二元词给文章打分,保留最多 12 篇命中候选;没有词面命中时,回退到最近的 3 篇,避免标题改写后完全失去比较对象。

主聊天 LLM 只能返回严格 JSON:

  • NEW:候选文章没有覆盖同一主题,生成新文件。
  • UPDATE:候选已有主题覆盖,当前来源带来事实、版本、案例或实践增量。
  • SKIP:没有实质增量。
  • REVIEW:无法可靠判断,停止写入。

UPDATE 的 target 必须精确来自候选路径。决策 JSON 不符合协议时会用纠正提示重试一次,第二次仍失败就是 REVIEW。这个阶段宁可不写,也不让模型猜一个文件名覆盖文章。

对于 UPDATE,模型生成的是目标文章的完整版本。合并提示要求删除同一章节中的纯重复表述,但必须保留命令、路径、配置键、错误信息和具体证据;原文件路径和发布日期保持不变。从扫描到写入由一把 asyncio.Lock 包住,文件先写到同目录临时文件,再用 Path.replace() 原子替换。并发到达的相同来源会按顺序重新判断,第二个请求不会同时创建另一篇新文。

front matter 是写入契约,不是提示词示例

这条链路最近遇到了一次类型错误。日报文章更新后出现:

published: '2026-08-07'

它在 YAML 中是字符串,而 Fuwari 的 published: z.date() 要求日期。问题不只在模型输出。旧生成逻辑只检查是否以 --- 开头;而 UPDATE 为了保留发布时间,将 Python 字符串交给 yaml.safe_dump(),序列化器自然写出了带引号的标量。

现在 BlogUpdateActor 在任何 NEW 或 UPDATE 写入前校验整个 front matter,规则镜像博客的 Fuwari collection:

  • title 是非空字符串,published 必须是未加引号的 YAML 日期。
  • updated 是日期;draft 和 featured 是布尔值。
  • description、image、lang 与 prevTitle、prevSlug、nextTitle、nextSlug 必须是字符串。
  • tags 必须是字符串数组,category 是字符串或 null,未知字段全部拒绝。

第一次失败时,LLM 会收到字段级反馈,例如 published must be an unquoted YAML date,再生成一次。第二次仍不通过,系统不发布,也不静默丢弃:原样保存第二次响应到 mini-agent 的 data/blog-review/,留给人工修复。保留发布日期时则先调用 date.fromisoformat() 还原为日期对象,再交给 YAML 序列化,避免再次变成字符串。

这里的变化是把“提示词里写了格式,所以模型大多会遵守”换成“只有通过站点 schema 的文件才能进入博客目录”。提示词负责引导,校验器负责准入。

写入完成后,BlogSyncTask 才接手

BlogUpdateActor 只负责得到一份可信的 Markdown,并原子写入博客目录。BlogSyncTask 的 watchdog 只关注 .md 变化,默认防抖 1200 秒后执行 Git 操作。它在没有新修改但本地仍有未推送提交时,也会继续 pull/rebase/push,避免手工提交或其他实例创建的提交滞留在本地。

这个拆分也适用于云端:BlogSyncTask 是文件 watcher,不依赖 scheduler 是否启用。只要配置了 BLOG_POSTS,云端实例仍会同步;两个实例先 pull --rebase 再 push,避免后推的一方直接拒绝。

验证范围

回归测试没有只验证“LLM 能输出一篇文章”。当前覆盖包括:

  • 空话题、话题隔离,以及 /query_end 无法发布其他用户的内容。
  • Web JSON、Web SSE 和 Telegram 查询在话题切换或结束后的响应归属。
  • 200 轮窗口的淘汰策略。
  • 语义重复跳过、增量合并、非法 UPDATE target,以及并发相同来源。
  • Fuwari 支持的全部 front matter 字段、错误类型、未知字段、带字段反馈的重试和二次失败进入 data/blog-review/。

截至本次改造,uv run pytest 完整测试集为 452 通过。本机当天的 4 篇文章也用同一个 schema 校验,再通过 pnpm check 的 Astro 内容检查。

自动发博客不需要一个对仓库有写权限的模型进程。现在的边界是明确的:session 决定输入归属,agent 进程负责写入,BlogUpdateActor 决定新建、合并或跳过,Fuwari schema 决定文件能否入库,BlogSyncTask 只负责同步 Git。

Sources

  1. mini-agent 043bba1 — 最初尝试把博客仓库 rw-bind 给沙箱 Pi,并加入 Git 网络重试与 Markdown 重排。
  2. mini-agent c893a36 — 撤掉 tenant workspace 模式下不会生效的博客 rw-bind。
  3. mini-agent 3a8ca8f — 把话题博客提取从沙箱 Pi 改到 agent 进程。
  4. mini-agent bb2e6ed — 引入显式 TopicSession,关闭跨租户话题泄漏。
  5. mini-agent 07bd1b8 — 固定异步响应的原 session,并保留长话题的最近 200 轮。
  6. mini-agent 6aa2a4c — 引入 BlogUpdateActor、去重决策和增量合并。
  7. mini-agent 17f6a41 — 全字段 Fuwari schema 校验、带反馈重试和人工 review 草稿。
  8. mini-agent core/topic_session.py — 当前 TopicSessionStore 实现。
  9. mini-agent tasks/blog_update.py — 当前候选筛选、决策、合并与 schema 校验实现。
  10. Fuwari 内容 schema — 博客 front matter 的类型定义。

Related