🤖 第四章 应用实战:今日头条批量发布助手
📄 一句话概括

批量的回收,难点不在【解析 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 补不出来,只能重生成)。

📝 为什么不干脆让模型【只输出 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 篇 —— 少的那些不是【等一等就齐了】,是【要去处理】

读结果,四个值得注意的地方:

  1. R2 是被【正则兜底】救回来的——它前面带开场白、外面裹着 ```json、结尾还有一句追问。如果只有直解,它会被误判成【致命】并去重试——而重跑一次它大概率还是这个形态(因为这是模型的说话习惯,不是随机噪声)。兜底那一段代码,救的不是异常,是习惯;
  2. R3 和 R5 都被判成【致命】,但原因不同:R3 是被截断(token 用完了,输出没写完),R5 是压根没按契约来(返回了散文)。两者都该重试,但值得顺手记一下——如果 R5 这种【跑成散文】反复出现,那说明 4.3.1 的设定卡该改了(多半是【输出格式】那段写得不硬),而不是一直重试烧钱;
  3. R6 是最值得琢磨的一条:它解析成功、字段齐全,唯一的毛病是 ai_flag 为假。按格式看它是完美的一篇;按合规看它是不能发的一篇。 它被正确安排进【降级】——这就是分级的意义:如果只做【格式校验】,它会一路走到上传那一步,然后在 4.4.4 被拦(或者更糟,没被拦);
  4. 最后的数字必须对得上账:6 进,2 + 2 + 2 出。 这是本节唯一一条不能妥协的纪律——通过 + 降级 + 致命 = 提交。对不上就是有稿子凭空消失,而【少了的那些不是等一等就齐了,是你要去处理】。
📝 注意输出里的正文只有 44 到 46 字

这几个数字低于 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 现状:本节设计的两处依据

  1. 模型输出不稳是长期现实,不会因为换代而消失:模型换代会让【跑成散文】变少,但不会让【开场白】和【代码块包裹】绝迹(那是对话产品的交互习惯)。所以两段式解析要一直留着;
  2. 【AI 声明缺字段】已经是有明确代价的错误:头条的落地要求里,AI 生成内容未标注落在【失实表述】这一档(扣 10 分)。所以 ai_flag 为假不是【格式瑕疵】,它必须被当成合规问题来处理——本节把它归进【降级、不进排版】是对的。

30 秒实验

把 Demo 里的兜底那一段临时注释掉(只留 json.loads),再跑一次。

会发生什么:

结论:兜底那几行代码,决定了【一篇好稿会不会被当成坏稿重试】。 而重试是有代价的(时间、额度、还有下一批排队等着的主题)。

更值钱的一层:R2 这种形态是【稳定的恶习】,不是随机噪声——重跑一次它还会长这样。所以【解析失败】的原因要分两类看:偶发截断(重试有用)与固定形态(该去改设定卡)。 分不清这两类,你就会陷入【一直重试、一直失败】的循环。

常见误区

  1. 【解析不出来就整批重来】——错。 那会让两篇坏稿废掉四篇好稿。正确做法是部分成功 + 留痕:坏的进队列,好的继续走,批次结束对一次账。
  2. 【只做格式校验,内容合规交给后面】——错,而且很常见。 本节 Demo 里的 R6 就是反例:它格式完美、内容不能发。 如果回收环节只看【有没有这几个字段】,它会一路滑到上传那一步。ai_flag 是合规字段,不是格式字段。
  3. 【凡是解析失败都重试】——错。 要分两类:偶发截断(重试有用)与固定形态的恶习(跑成散文、总加开场白)——后者该去改 4.3.1 的设定卡,不是一直重试烧钱。
  4. 【静默跳过就行,反正日志里有】——错。 你不会去翻日志,而排期和复盘会基于那张缺了记录的任务表。必须在对账时把三类数字打出来,且必须相等。
  5. 【每一道关都把能查的全都查一遍,更保险】——错。 内容质量归 4.3.2,格式能不能上平台归 4.4.3,本节只管【结构上能不能接住】。一个检查器什么都管,最后什么都管不住,还会让人养成【看到告警就无视】的习惯。
自测三问(点开对答案) 1. **为什么批量的回收要分【通过 / 降级 / 致命】三档,而不是【能用 / 不能用】两档?** 因为如果只有两档,你会被迫二选一,而**两个选择都是错的**:把降级项判成失败 → **在技术重试上反复烧钱,而它们重跑多少次都还是坏的**;把降级项当成通过 → **缺 AI 声明的稿子会一路滑到上传环节**,离扣分只差一次点击。**分档的依据只有一条:这个错,重跑一遍能修好吗?能 → 致命(重试);不能 → 降级(交人)。** 2. **为什么解析要写【直解 + 大括号兜底】两段?** 因为模型返回的**不是干净 JSON 是常态**(带开场白、裹代码块、结尾追问)。直接 `json.loads` 会把**这些本来合格的稿子误判成【致命】并去重试**——而重跑一次它们大概率还是这个样子,**因为这是模型的说话习惯,不是随机噪声**。兜底那几行代码救的不是异常,是习惯。 3. **本节唯一不能妥协的纪律是什么?** **对账:通过篇数 + 降级篇数 + 致命篇数 = 提交篇数。** 因为**留痕是三条失败原则里最重要的一条**——跳过和重试只影响效率,**留痕影响可信度**。一张不说实话的任务表比空表更危险:你不知道少了哪几篇,后面所有排期、复盘、选题都会建立在错误前提上。

相关笔记