批量的回收,难点不在【解析 JSON】,而在分级:不是全过,也不是全停,而是把每条结果判到三档之一——通过、降级、致命。判断对了,一篇坏稿不会拖垮整批;判断错了,要么整批停摆,要么坏稿偷偷流到发布环节。
4.4.1 把主题送进去,本节把它们的结果收回来,并交给 4.4.3 排版。第三章 3.4.2 已经讲过【分级阻断】这套方法,本节是把它从【一篇】扩大到【一批】——多出来的新问题只有一个,但它很关键:一批里有几篇坏掉时,剩下的还要不要走。
一、先定一件事:坏消息要分几档
单篇的时候,结果只有两种:能用 / 不能用。批量的时候,必须分三档,否则你会被迫做两个都不对的选择。
| 档 | 什么情况 | 怎么处理 | 为什么 |
|---|---|---|---|
| 致命 | 解析不出来(不是 JSON、被截断、跑成了散文) | 标【失败】,等技术重试 | 这是技术性问题,重跑一次多半就好 |
| 降级 | 解析出来了,但内容不合规或缺字段(缺标签、缺 AI 声明、缺正文) | 记下来,不进排版,交给人 | 这是内容问题,重跑还是坏的 |
| 通过 | 字段齐、长度够、ai_flag 为真 |
进入排版队列 | —— |
- 如果只有【通过/失败】两档:所有降级项都被判成失败 → 你会在技术重试上反复烧钱,而它们重跑多少次都还是坏的;
- 如果全按【通过】处理:缺 AI 声明的稿子会一路走到上传环节——而此时它离那条【扣 10 分】的处罚只差一次点击。
所以三档不是【更细】,是【必需】。 它的分界线只有一条:这个错,重跑一遍能修好吗?能 → 致命(重试);不能 → 降级(交人)。
二、解析这一层:两段式,先直解再兜底
模型返回的 JSON 经常不是干净 JSON,这是常态而不是异常:
| 常见形态 | 长什么样 |
|---|---|
| 前面带开场白 | 好的,这是这一批的第 2 篇: + JSON |
| 外面裹代码块 | ```json + JSON + ``` |
| 后面带追问 | JSON + 需要我继续吗? |
| 被截断 | JSON 少一个右大括号(token 用完了) |
| 干脆不是 JSON | 返回了一段散文(「这篇可以从三个角度写……」) |
所以解析要写两段:
第一段:直接 json.loads() → 成功就用它("直解")
第二段:从第一个 { 到最后一个 } 取出来再试 → 成功就用它("正则兜底")
两段都失败 → 判【致命】,进重试队列
第二段能救回来的比你想的多——开场白、代码块包裹、结尾追问,这三种都能靠【取大括号块】救回来。但它救不了截断(缺右括号的 JSON 补不出来,只能重生成)。
因为那降低概率,不消除概率。4.3.1 的设定卡让你把输出格式写死,正是为了把【不是 JSON】的概率压到最低——但批量跑几十次,低概率事件一定会发生。 所以设定卡管【减少】、解析器管【兜住】——两层都要有。
三、批量新增的那一个问题:部分成功怎么办
单篇不存在这个问题。批量时,一批 6 篇里有 2 篇坏掉,剩下 4 篇要不要走?
| 策略 | 后果 | 评价 |
|---|---|---|
| 整批停 | 2 篇坏稿废掉 4 篇好稿 | 错——损失被放大 |
| 静默跳过 | 4 篇继续走,但你不知道少了 2 篇 | 错——丢稿无痕,最危险 |
| 部分成功 + 留痕 | 4 篇继续走,2 篇进不同队列,批次结束汇总 | 对 |
这正是 4.2 第五节那三条失败原则(跳过、重试、留痕)在回收环节的落地:
- 跳过 = 坏的那篇只改自己的状态,不影响其他篇的流转;
- 重试 = 致命档进重试队列(技术性问题,自动重跑);
- 留痕 = 批次结束时必须打印一张清单:通过几篇、降级几篇(各是什么原因)、致命几篇。
因为前两条只影响效率,第三条影响可信度。一张不说实话的任务表,比一张空表更危险——你不知道少了哪几篇,就会在排期、复盘、下一次选题时全部基于错误前提。判据很简单:批次结束时,【通过的篇数 + 降级的篇数 + 致命的篇数】必须等于【提交的篇数】。 对不上,就是有稿子凭空消失了。
四、动手 Demo:一次回收六条结果,看三档怎么分
下面这段代码只用 Python 标准库。它故意准备六种真实的脏返回,然后逐条判档、汇总,并输出交给 4.4.3 的清单。
# -*- coding: utf-8 -*-
"""4.4.2 Demo:批量回收创作结果 —— 分级阻断,一篇失败不拖垮整批(纯标准库)"""
import json
import re
# 智能体这一批的返回(故意做脏:六种真实会遇到的形态)
RETURNS = [
# R1 干净 JSON
'{"id":"T01","topic":"玄关收纳","title":"玄关收纳三件套,我最后只留了这三样",'
'"lead":"玄关永远堆着鞋和快递盒。","body":"玄关永远堆着鞋和快递盒,出门前翻三分钟找不到钥匙。'
'我把玄关重新收拾了一遍,只留三件东西。","tags":["收纳","玄关","小户型"],'
'"cover_prompt":"浅木色玄关,自然光","ai_flag":true}',
# R2 夹开场白 + ```json 包裹
'好的,这是这一批的第 2 篇:\n~~~json\n{"id":"T02","topic":"厨房台面","title":"厨房台面总是乱?先划三个区",'
'"lead":"台面乱,多半不是东西太多。","body":"台面乱,多半不是东西太多,而是没有分区。'
'把台面按使用频率划成三个区:备菜区、灶台区、出菜区。","tags":["厨房","收纳","分区"],'
'"cover_prompt":"厨房台面分区","ai_flag":true}\n~~~\n需要我继续吗?',
# R3 JSON 被截断
'{"id":"T03","topic":"阳台改造","title":"三千块改完阳台","lead":"改造阳台最难的是控制预算。",'
'"body":"改造阳台最难的不是设计,是控制预算。","tags":["阳台"',
# R4 缺 tags 字段
'{"id":"T04","topic":"衣柜换季","title":"换季衣柜整理,我扔了三十件",'
'"lead":"扔东西比买收纳盒有用。","body":"扔东西比买收纳盒有用得多,我这次清出三十件。",'
'"cover_prompt":"衣柜","ai_flag":true}',
# R5 返回了一段散文,根本不是 JSON
'这篇文章可以从三个角度来写:第一是预算,第二是施工顺序,第三是验收。建议先做预算表。',
# R6 干净 JSON,但 ai_flag 为假
'{"id":"T06","topic":"书桌线缆","title":"书桌线缆整理,十分钟搞定",'
'"lead":"桌下线缆乱,问题出在插座位置。","body":"桌下线缆乱,问题通常出在插座的数量和位置上。'
'先固定理线槽,再把每根线收到刚好够用。","tags":["书桌","理线","桌面改造"],'
'"cover_prompt":"桌下理线槽","ai_flag":false}',
]
NEED = ["id", "topic", "title", "lead", "body", "tags", "cover_prompt", "ai_flag"]
FATAL, SOFT, PASS = [], [], []
def try_json(text):
"""解析:先直解,失败再走【掐头去尾取第一个大括号块】的兜底"""
try:
return json.loads(text), "直解"
except Exception:
pass
m = re.search(r"\{.*\}", text, re.S) # 兜底:从第一个 { 到最后一个 }
if m:
try:
return json.loads(m.group(0)), "正则兜底"
except Exception:
pass
return None, "失败"
print("=" * 64)
print("一、逐条回收(共 %d 条)" % len(RETURNS))
print("=" * 64)
for i, raw in enumerate(RETURNS, 1):
rid = "R%d" % i
obj, how = try_json(raw)
if obj is None:
FATAL.append((rid, "无法解析为 JSON(连大括号兜底都失败)"))
print(" [致命] %-3s 解析失败 —— 这一篇标【失败】,等重试" % rid)
continue
miss = [k for k in NEED if k not in obj or obj[k] in ("", [], None)]
if miss:
SOFT.append((rid, obj.get("id", "?"), "缺字段 " + "、".join(miss)))
print(" [降级] %-3s 解析成功(%s),但缺 %s —— 记下,不进排版" % (rid, how, "、".join(miss)))
continue
if not obj.get("ai_flag"):
SOFT.append((rid, obj["id"], "ai_flag 为假,缺 AI 声明"))
print(" [降级] %-3s 解析成功(%s),但 ai_flag 为假 —— 记下,不进排版" % (rid, how))
continue
PASS.append((rid, obj))
print(" [通过] %-3s 解析成功(%s) %s | 标题 %d 字 | 正文 %d 字"
% (rid, how, obj["id"], len(obj["title"]), len(obj["body"])))
实际运行输出(真跑,不是示意):
================================================================
一、逐条回收(共 6 条)
================================================================
[通过] R1 解析成功(直解) T01 | 标题 17 字 | 正文 44 字
[通过] R2 解析成功(正则兜底) T02 | 标题 13 字 | 正文 46 字
[致命] R3 解析失败 —— 这一篇标【失败】,等重试
[降级] R4 解析成功(直解),但缺 tags —— 记下,不进排版
[致命] R5 解析失败 —— 这一篇标【失败】,等重试
[降级] R6 解析成功(直解),但 ai_flag 为假 —— 记下,不进排版
================================================================
二、批次结论
================================================================
通过 2 篇 / 降级 2 篇 / 致命 2 篇
--- 需要重试的(技术性,可自动重跑)---
R3 无法解析为 JSON(连大括号兜底都失败)
R5 无法解析为 JSON(连大括号兜底都失败)
--- 需要人看的(判断性,别自动补)---
R4 T04 缺字段 tags
R6 T06 ai_flag 为假,缺 AI 声明
================================================================
三、交给 4.4.3 排版的清单
================================================================
R1 T01 玄关收纳三件套,我最后只留了这三样
R2 T02 厨房台面总是乱?先划三个区
共 2 篇 —— 少的那些不是【等一等就齐了】,是【要去处理】
读结果,四个值得注意的地方:
R2是被【正则兜底】救回来的——它前面带开场白、外面裹着```json、结尾还有一句追问。如果只有直解,它会被误判成【致命】并去重试——而重跑一次它大概率还是这个形态(因为这是模型的说话习惯,不是随机噪声)。兜底那一段代码,救的不是异常,是习惯;R3和R5都被判成【致命】,但原因不同:R3是被截断(token 用完了,输出没写完),R5是压根没按契约来(返回了散文)。两者都该重试,但值得顺手记一下——如果R5这种【跑成散文】反复出现,那说明 4.3.1 的设定卡该改了(多半是【输出格式】那段写得不硬),而不是一直重试烧钱;R6是最值得琢磨的一条:它解析成功、字段齐全,唯一的毛病是ai_flag为假。按格式看它是完美的一篇;按合规看它是不能发的一篇。 它被正确安排进【降级】——这就是分级的意义:如果只做【格式校验】,它会一路走到上传那一步,然后在 4.4.4 被拦(或者更糟,没被拦);- 最后的数字必须对得上账:6 进,2 + 2 + 2 出。 这是本节唯一一条不能妥协的纪律——通过 + 降级 + 致命 = 提交。对不上就是有稿子凭空消失,而【少了的那些不是等一等就齐了,是你要去处理】。
这几个数字低于 4.3.2 的 800 字建议值,也低于平台的 100 字底线——但本节没有把字数检查放在这里,而是留给了 4.3.2 的契约体检与 4.4.3 的排版前体检。 为什么:每一道关只管它该管的事。 本节管的是【结构上能不能接住】(是不是 JSON、字段齐不齐),内容质量归 4.3.2,格式能不能上平台归 4.4.3。一个检查器什么都管,最后什么都管不住。
五、把结果落进任务表
回收完之后,回写任务表是这一步的收尾动作(4.2 的状态机):
| 档 | 回写什么状态 |
|---|---|
| 通过 | 已出稿,并把 md_path 写上 |
| 降级 | 失败(或专门的【需人处理】态),error 写清原因 |
| 致命 | 失败,error 写【解析失败】,可自动重试 |
注意降级项也回写成失败态——因为它确实走不下去了。它跟致命项的区别不在【状态】,而在【谁来处理】:致命项交给重试逻辑,降级项交给人的队列。任务表只记状态,处理方式记在 error 里。
六、⚠️ 2026 现状:本节设计的两处依据
- 模型输出不稳是长期现实,不会因为换代而消失:模型换代会让【跑成散文】变少,但不会让【开场白】和【代码块包裹】绝迹(那是对话产品的交互习惯)。所以两段式解析要一直留着;
- 【AI 声明缺字段】已经是有明确代价的错误:头条的落地要求里,AI 生成内容未标注落在【失实表述】这一档(扣 10 分)。所以
ai_flag为假不是【格式瑕疵】,它必须被当成合规问题来处理——本节把它归进【降级、不进排版】是对的。
30 秒实验
把 Demo 里的兜底那一段临时注释掉(只留 json.loads),再跑一次。
会发生什么:
R2会从【通过】变成【致命】——它明明是一篇合格的稿子;- 批次结论会从 2/2/2 变成 1/2/3——致命项多了一条。
结论:兜底那几行代码,决定了【一篇好稿会不会被当成坏稿重试】。 而重试是有代价的(时间、额度、还有下一批排队等着的主题)。
更值钱的一层:R2 这种形态是【稳定的恶习】,不是随机噪声——重跑一次它还会长这样。所以【解析失败】的原因要分两类看:偶发截断(重试有用)与固定形态(该去改设定卡)。 分不清这两类,你就会陷入【一直重试、一直失败】的循环。
常见误区
- 【解析不出来就整批重来】——错。 那会让两篇坏稿废掉四篇好稿。正确做法是部分成功 + 留痕:坏的进队列,好的继续走,批次结束对一次账。
- 【只做格式校验,内容合规交给后面】——错,而且很常见。 本节 Demo 里的
R6就是反例:它格式完美、内容不能发。 如果回收环节只看【有没有这几个字段】,它会一路滑到上传那一步。ai_flag是合规字段,不是格式字段。 - 【凡是解析失败都重试】——错。 要分两类:偶发截断(重试有用)与固定形态的恶习(跑成散文、总加开场白)——后者该去改 4.3.1 的设定卡,不是一直重试烧钱。
- 【静默跳过就行,反正日志里有】——错。 你不会去翻日志,而排期和复盘会基于那张缺了记录的任务表。必须在对账时把三类数字打出来,且必须相等。
- 【每一道关都把能查的全都查一遍,更保险】——错。 内容质量归 4.3.2,格式能不能上平台归 4.4.3,本节只管【结构上能不能接住】。一个检查器什么都管,最后什么都管不住,还会让人养成【看到告警就无视】的习惯。
自测三问(点开对答案)
1. **为什么批量的回收要分【通过 / 降级 / 致命】三档,而不是【能用 / 不能用】两档?** 因为如果只有两档,你会被迫二选一,而**两个选择都是错的**:把降级项判成失败 → **在技术重试上反复烧钱,而它们重跑多少次都还是坏的**;把降级项当成通过 → **缺 AI 声明的稿子会一路滑到上传环节**,离扣分只差一次点击。**分档的依据只有一条:这个错,重跑一遍能修好吗?能 → 致命(重试);不能 → 降级(交人)。** 2. **为什么解析要写【直解 + 大括号兜底】两段?** 因为模型返回的**不是干净 JSON 是常态**(带开场白、裹代码块、结尾追问)。直接 `json.loads` 会把**这些本来合格的稿子误判成【致命】并去重试**——而重跑一次它们大概率还是这个样子,**因为这是模型的说话习惯,不是随机噪声**。兜底那几行代码救的不是异常,是习惯。 3. **本节唯一不能妥协的纪律是什么?** **对账:通过篇数 + 降级篇数 + 致命篇数 = 提交篇数。** 因为**留痕是三条失败原则里最重要的一条**——跳过和重试只影响效率,**留痕影响可信度**。一张不说实话的任务表比空表更危险:你不知道少了哪几篇,后面所有排期、复盘、选题都会建立在错误前提上。- ☐ 下一节(4.4.3 RPA在墨滴平台排版)
相关笔记
- RPA提交多轮对话信息并获取创作结果 —— 分级阻断的出处,本节是它的批量版
- RPA输入创作主题 —— 上一节的请求,本节把它的结果收回来
- 今日头条批量发布助手工作流程设计 —— 失败三原则与状态机的出处
- 创建文心智能体的方式 —— 【输出格式】那一段写得不硬,就会变成这里的反复重试
- 🤖 Agent+RPA实战 · 索引 —— 全课路线图