旧的博客流程为什么会卡住
mini-agent 原来的博客发布链路并不粗糙。ResultBlogActor 把单次 query 或 topic session 转成 BlogSource,BlogUpdateActor 扫描既有文章、筛选候选、让模型决定 NEW、UPDATE、SKIP 或 REVIEW,再生成或合并 Markdown,校验 Fuwari frontmatter,最后用临时文件加 Path.replace() 写入。
这套流程解决了两个实际问题:同一主题不会只因为换了问法就生成第二篇文章;已有文章出现新事实时,可以更新旧文而不是另起文件。它也有明确的失败边界。判重 JSON 不合法、目标路径不在候选中、frontmatter 连续两次校验失败,都会停止发布。
问题出在它是固定编排。模型先被要求做判断,再被要求写文章,格式不对时给一次重排提示。这个顺序很适合简单任务,却很难处理更像编辑工作的情况:模型可能需要先读完整会话,再看候选文章全文;发现旧文确实相关后,还要比较新增内容;草稿写完后,可能因为遗漏一次被纠正的尝试、细节太少或噪声太多而必须重写。
把这些分支继续塞进 if/else,流程会越来越长,模型却仍然只能被动接收下一条提示。于是这次改造的目标不是增加一个“更聪明的博客 prompt”,而是把博客生成变成一个边界明确的 workflow agent。
先把 agent 的自由度关进门里
这里的 agent 不负责写文件,也不拥有任意文件读取权限。它的工作是在受限工具之间选择下一步,并根据工具结果修改草稿。发布权仍在宿主代码手里。
这一区分比“有没有 tool calling”更重要。文件路径是否合法、frontmatter 是否符合 schema、更新文章是否保持原 published 日期、最终稿是否就是刚刚校验过的那一版,这些都能用规则判断。把它们交给模型自评没有意义。
语义问题则不同。两篇文章是否覆盖同一主题,当前会话有没有带来实质新增,草稿是否保留了技术探索里后来被纠正的弯路,不能靠关键词交集或正则可靠完成。这些问题交给独立的 LLM judge,并要求它返回严格 JSON。
| 问题 | 由谁判断 | 原因 |
|---|---|---|
| 候选路径、分页范围、frontmatter、旧文章发布日期 | 宿主规则 | 输入和结果都能确定性验证 |
NEW / UPDATE / SKIP / REVIEW | semantic judge | 需要比较主题覆盖和信息增量 |
| 草稿是否保留技术事实、修正过程和足够细节 | quality judge | 需要理解会话与文章之间的语义关系 |
| 密钥、token、私钥和凭据格式 | 宿主规则 | 不能把安全判断交给生成模型自述 |
这样做的结果是,模型可以像编辑一样反复查资料和修稿,但不能通过一句“我已经检查过了”绕过发布条件。
trace 的停止和 workflow 的停止是两件事
这次给 LLMClient 增加了 chat_with_tools()。它把 JSON-schema 工具绑定到 LangChain chat model,保留 AIMessage.tool_calls 和 ToolMessage 的往返记录。BlogUpdateActor 在持有原有 asyncio.Lock 的情况下创建一条 trace,模型每一轮可以调用下面这些工具:
inspect_source分页读取已脱敏的来源会话;find_candidate_posts和read_post查看候选文章;judge_semantic_overlap请求判重;judge_draft_quality审查草稿的信息覆盖和噪声;scan_sensitive_data扫描敏感数据;validate_blog_format校验 Fuwari Markdown 并计算 hash。
模型某一轮不再调用工具时,当前 trace attempt 结束。它只能输出两种 JSON 信封:
{"outcome":"publish","markdown":"完整 Markdown"}
或:
{"outcome":"skip","reason":"简短理由"}
这里很容易把“模型停止调用工具”误当成“工作已经完成”。实际上,宿主会在这一步检查最近一次 judge、格式校验、脱敏扫描和最终 Markdown 的对应关系。任何门阀失败都会把不含敏感原文的反馈追加回消息历史,重新开始下一次 attempt。workflow 只有在所有门阀通过时才结束;BLOG_TRACE_MAX_STEPS 只是给总 attempt 和工具回合设置上限,具体值留给后续 benchmark 调整。
后来又补了一层 SourcePolicy,专门把旧流程的业务标准带回 trace。single 仍要求单次回答具备独立技术价值、一定深度或广度,不能只是简单问答或个人事务;topic 则必须综合所有 turns,保留有证据的探索、后续纠正和最终结论,不能只拿最后一轮生成。两种模式继续共用安全、格式和 hash 门阀,但 semantic judge、quality judge 和 trace 初始指令都会收到对应的业务标准。空来源或没有可识别 turns 的 topic 由宿主直接进入 review,不再让模型反复消耗步数。
最终稿为什么要和校验结果绑定
格式校验本身并不复杂。validate_blog_format 会复用已有的 frontmatter schema;对 UPDATE,它先把旧文章的 published 日期写回新稿,再返回通过状态和 Markdown 的 SHA-256。
真正需要防的是校验之后的变化。模型可能先拿一份合法草稿调用格式工具,随后在最终 JSON 中换成另一份内容。如果只记录“格式校验曾经通过”,这条替换就能绕过门阀。
因此发布前会比较最终 Markdown 与最近一次校验后的 Markdown 是否完全一致。格式正确不再是一个抽象状态,而是某个确定内容的属性。更新文章时,目标路径也必须仍在本轮候选中,且旧文件仍存在;否则流程进入 review,不会退化为新建文章。
这一层没有引入新的写入机制。通过门阀后,代码仍走原来的同目录临时文件和 Path.replace(),并继续交给 BlogSyncTask 做后续的 Git commit、rebase 和 push。并发请求仍由 actor lock 串行化,避免两个相同来源基于同一份旧索引同时新建文章。
脱敏不是让模型“注意一点”
博客来源来自 query 结果和 topic 会话。它们可能包含用户粘贴的 token、连接字符串,甚至历史文章里遗留的凭据。只在 prompt 里要求模型不要泄露不够,因为模型仍然已经看到了原文。
现在的处理分成三层。
第一层发生在进入任何 LLM prompt 之前。来源 topic、来源内容、候选文章摘要和候选文章全文都经过同一个 sanitizer。它会遮蔽运行时已知的 DEEPSEEK_API_KEY、TELEGRAM_BOT_TOKEN、WEB_API_PASSWORD 等值,也会识别 bearer token、私钥块、JWT、云访问密钥和常见的密钥赋值形式。模型看到的是类别化占位符,例如 <REDACTED_DEEPSEEK_API_KEY>。
第二层是模型可调用的 scan_sensitive_data。扫描结果只返回行号、类别和替换标记,不回显匹配到的 secret。模型可以据此重写草稿,而不是把敏感值再带进一次工具反馈。
第三层在最终发布门阀。宿主会重新扫描最终 Markdown,并要求它的 hash 与最近一次脱敏扫描的版本相同。这个双重检查处理了一个很具体的绕过方式:模型先扫描安全版本,随后在最终 JSON 中改回敏感内容。
实现过程中,测试还碰到一个小但典型的问题:token: <REDACTED_DEEPSEEK_API_KEY> 会再次命中“敏感赋值”正则。扫描器现在显式放行标准 <REDACTED_...> 占位符。否则它会把已经脱敏的草稿反复判为不安全,白白耗尽 workflow 步数。
judge 负责语义,规则负责拒绝不合格输入
judge_semantic_overlap 沿用了原有的 NEW、UPDATE、SKIP、REVIEW 协议,但它现在是模型能主动调用的工具。UPDATE 的 target 必须精确匹配本轮候选路径;无候选或判断不可靠时也不能猜一个路径。
judge_draft_quality 则对照完整来源和草稿。它要求文章保留有价值的技术事实、命令、路径、配置、错误和结论。会话中出现过错误方向、后来被证据修正的尝试,也要保留成“曾经如何判断、为什么不成立、后来如何修正”的上下文。能删的是寒暄、重复表述和无关分支。
judge 返回 REVISE 时,模型还有机会在剩余步数里改稿。judge 超时、返回错误 JSON、更新目标消失、最终 JSON 信封无法解析,都会 fail closed。流程不会因为模型没有给出答案就默认新建一篇文章。
这也是这次改造和单纯“多加几次 retry”的差别。retry 只是在同一条指令失败后再问一次;workflow agent 拿到的是哪一道门没过、为什么没过,以及哪些工具还能用。
失败结果也要可查,但不能把秘密留在 review 里
失败 trace 会在 data/blog-review/ 写入最终草稿和同名 JSON 摘要,摘要包含停止原因、内容 hash 与门阀状态。review 解决的是人工判断问题,不适合承担长期调参和故障分析:它不是每次都有,也不该靠人工翻文件统计。
review 不是安全例外。写入 review 前,草稿和诊断都会再次经过 sanitizer。这样人工可以看到格式错误、judge 冲突或步数耗尽的原因,却不会因为一次失败发布把原始凭据存进本地审阅目录。
步数应该由真实会话决定
配置里增加了:
blog:
trace_max_steps: ${BLOG_TRACE_MAX_STEPS:-8}
runner 不再有自己的经验常量。这个初值不是“最佳步数”的结论,真实数值需要根据结果日志里的完整 topic 来调。
为此新增了 scripts/blog_trace_benchmark.py。它从 result-*.log 的 [query_start] 到 [query_end] 提取多轮 query_topic,为每个候选步数复制一份临时博客目录和临时 review 目录,再运行同一个 BlogUpdateActor。它输出每个步数下的文章数量变化与 review 数量,不会修改真实博客目录。
这次实际跑了一轮隔离 benchmark。runner 使用 systemd 的加密 DeepSeek 凭据和常驻服务的 .env,从 data/logs/result-2026-06-10.log 选取 loop engineering topic(来源内容 14,859 字符),对 6、8、10 三个步数分别复制博客目录和 review 目录后运行同一个 actor。三次都在首次模型请求处收到 APIConnectionError,耗时分别为 2.318s、1.372s、1.571s;状态都是 review,changed_posts 都是 0。真实博客目录没有被修改。
随后做了无 key 的网络复核:沙箱内解析不了 api.deepseek.com;提权后访问本机配置的精确 base URL 时,继承的 HTTPS_PROXY 指向本机 127.0.0.1,代理端口拒绝连接;清除代理后请求又持续等待。这把本地失败定位到了运行环境网络,而不是 workflow 门阀逻辑。
真正的生产验证转移到了 ECS。代码提交 a30a44a 和后续的日志解析修复 38f4342 先推到 origin,再由 ECS fast-forward pull、uv sync 并重启到 38f4342;服务状态为 active。benchmark 使用 ECS 的 .env、生产博客目录的临时副本和真实结果日志,未改动生产文章。
第一条完整样本是 u1 对象存储平台,来源 6,627 字符:4 步为 review / trace step limit reached,6 和 8 步为 review / invalid final envelope,10 步为 skipped,耗时 9.877 秒。第二条长样本是 android-chatgpt-style-apps-case-study,来源 9,712 字符:4、6、8、10、12、16 步均为 review(步数耗尽或最终信封无效),18 步为 review / model failure: BadRequestError,20 步首次得到 skipped,耗时 20.717 秒。一个只有 71 字符的 FastAPI topic 在 4/6/8/10 步都 review,因此不把它当作长文最佳步数样本。
这组生产结果给出的不是“所有会话都必须 20 步”,而是一个可解释的配置建议:短 topic 可以在 10 步附近结束,长 topic 的首次稳定终态出现在 20 步;如果生产只保留一个默认值,当前先用 BLOG_TRACE_MAX_STEPS=20。选择规则仍是发布通过率优先,其次 review 率和耗时,最后才是步数尽量小。
用生产日志继续校准,而不是再猜一次
一次 benchmark 只能说明某几个样本发生了什么,不能替生产流量下结论。20 是当前的保守候选值,不是全局最优值。为了让后续调整有证据,workflow 每次结束都会向 data/logs/blog-workflow.jsonl 追加一条 JSONL 终态记录。这个文件不按天轮转,因为流量低,分析时需要跨天比较同一套规则和模型行为。
每条记录包含 trace_id、时间、source_kind、脱敏 topic、来源内容 hash 和长度,以及 max_steps、steps_used、publish/skip/review 终态、耗时、judge 结论、工具调用顺序、review_reason、异常类型和 review 文件名。它不保存原始会话、草稿、工具参数、工具结果或 secret。日志本身写入失败只记错误,不能改变本次博客发布结果。
这让“步数不够”和“系统有 bug”不再混在一起。只有在相近来源上,提高上限把 trace step limit reached 转成正常的 publish 或 skip,才把它计入步数不足。BadRequestError、无效 JSON 信封、格式门阀、网络和写入异常都单独归类。之后可以按 single/topic、来源长度、模型版本和配置值看通过率、review 率和耗时,再决定是否把 20 调低或调高。
本机网络问题也因此没有被误记成模型能力不足。沙箱里 DNS 无法解析 api.deepseek.com;提权后,本机 .env 的 HTTPS_PROXY 指向 127.0.0.1,代理端口拒绝连接;清除代理后直连一直等待。这个链路无法给出有效 benchmark,所以调参改到 ECS 生产环境完成。本机继续承担 Telegram 服务端,ECS 才是博客 workflow 的观测来源。
验证留下了哪些证据
这次提交新增了工具 trace、敏感数据扫描、benchmark parser、来源类型策略和工作流终态日志。测试覆盖完整工具序列发布、semantic judge、quality judge、格式与 hash 绑定、敏感数据反馈后重开 workflow、single/topic 的业务标准、review 脱敏、JSONL 的 publish/skip/review 记录,以及 result log 的 topic 提取。
最终执行了:
uv run pytest
graphify update .
在加入工作流终态日志后,pytest 全量结果为 449 passed;ECS 上的博客模块测试为 40 passed。这些测试验证实现和失败路径,步数配置仍只引用 ECS 的真实 benchmark 与后续生产 JSONL 数据。
博客生成本身仍然依赖模型判断,尤其是判重和内容质量。改造的重点不在于假装把这些问题变成确定算法,而是把模型擅长的语义判断与代码擅长的约束、验证和写入分开。模型可以反复工作,发布条件不能跟着变松。
Sources
- ccf10de: add guarded blog workflow trace — 本文涉及的代码、测试和 benchmark CLI。
- 157ab00: design blog workflow agent trace — 改造前确认的工具边界、门阀和失败策略。
- a482eae: preserve single and topic blog policies — 将旧流程的 single/topic 业务标准带回 workflow trace。
- aa74136: log blog workflow outcomes — 生产 JSONL 终态观测和对应回归测试。