🤖 第五章 应用实战:知乎图文创作和发布助手
📄 一句话概括

这一步做两件事:把任务卡送进去,把稿子取回来。听起来比第三章的 3.4.2 简单,实际多了一道工序——取回来之后要当场验收。原因很实在:它取回来的不一定是任务卡要的那一份(角度可能跑了、形态可能错了),而后者才是真正贵的错误。

📝 本节和前面几节的关系

5.3.1 出任务卡 → 5.3.2 把任务卡变成初稿(在智能体那一侧)→ 本节负责两端之间的搬运:把卡送进去、把稿取出来,并顺手跑一遍验收。之后交给 5.2 的第三格【补人味】(人做),再进 5.4.2 排版。本节是 Agent 侧与 RPA 侧的交界处。

一、RPA 在这里扮演什么角色

一个搬运工。 它不生成内容、不改内容,只做三件事:

① 把任务卡原文贴进对话输入框 → 发送
        ↓
② 等生成结束(等什么?见第四节)
        ↓
③ 把最后一段返回读出来 → 验收 → 写回任务表

为什么必须由 RPA 来做,而不是人手动复制? 因为这一步是纯机械动作(贴一段文字、等、读一段文字),而且它每篇都要重做一遍。这正是 1.4.2 讲的 RPA 适用条件:规则明确、高频、不含量判断。

📝 顺带说一句工具选择

如果平台提供了 API(扣子、文心都提供),优先走 API——它比界面稳定得多(不受改版影响、不用等页面渲染)。 但本节的写法按【界面操作】描述,有两个理由:① 界面的做法在所有平台上通用,而 API 各家的鉴权与格式都不一样;② 本课的取稿场景恰好是【对话】——在很多平台上,用界面把话说完、把结果拿走,比调 API 更简单。 判断原则不变:能用 API 就用 API(更稳),只能用界面就界面(更通用)。

二、发起会话:把任务卡【原样】送进去

这一步只有一个要求,但很容易违反:不要顺手改写。

✗ 错的:把任务卡【理解一下】,用自己的话重新组织再发
✓ 对的:把 5.3.1 产出的任务卡原文,一字不改地贴进去

为什么不能改? 因为任务卡里的每一个字段都是对标算出来的结论: - 【切入角度:成本预算】——这是 5.3.1 从缺口里挑出来的,你顺手改一个词,可能就改回了那个挤了 3 篇的红海角度; - 【必须包含至少 3 处具体数字】——这是为了过人味检查(5.3.2 六项里的第三项); - 【形态:回答】——这个字段决定下游 5.4.3 走哪套操作(三路形态互不通用)。

一句话:任务卡是【上游的结论】,不是【给模型的提示词草稿】。 你改它,等于把上游所有工序的产出作废。

三、取稿的三个难点

难点一:稿子在最后一段,不在第一段

多轮对话的返回是一整段文本(可能有开场白、可能有追问、可能有多次修改的痕迹)。要取的字段在最后那一轮里——所以解析要从结尾往上找,不能从头往下找。

难点二:格式会飘

即便提示词里明确要求【只输出 JSON】,实际返回也常常夹着解释文字(比如开头一句【好的,我按成本预算这个角度写了一版:】,结尾一句【需要我再补充吗?】)。

所以解析要分两档做: 1. 先试直接解析(纯 JSON,最理想); 2. 失败就用正则兜底(从第一个 { 取到最后一个 },再试一次); 3. 两档都失败才算失败——不能一失败就报错,那样会把大量【其实能救】的返回丢掉。

这套两档解析与 3.4.2 完全一致,是这一课的固定做法。

难点三(本章新增):取到的不一定是【你要的那一份】

这是本节与 3.4.2 最大的不同,也是本章多出来的那道工序。

模型可能交给你一份格式完全正确、内容也很像样的稿子——但它的形态写成了【文章】(任务卡要的是【回答】)、或者角度写成了【原理科普】(任务卡要的是【成本预算】)。

为什么这比格式错误更危险? 因为格式错误你能一眼看出来;【内容好但方向错】你看不出来。 它会被当成合格稿一路往下走,一直走到发布前,你才发现【这不是我要的角度】——那时候前面几步全白做了。

所以验收必须比对着任务卡做,不能只查格式:

查什么 比对着谁
格式对不对(JSON 能解析、字段齐) 结构
形态对不对 任务卡
角度对不对 任务卡
字数在不在区间 任务卡
人味够不够 5.3.2 的六项

注意最后一行:人味六项不需要等到人工检查时才用——接在取稿后面,它就成了一道【自动质检】。这是把 5.3.2 接进流水线的位置。

四、动手 Demo:取稿验收器

它做两关:第一关解析(这段返回里到底有没有一份能用的稿子),第二关验收(取到的这份是不是任务卡要的那一份)。

它要处理四种真实返回:

编号 返回长什么样 正确的处理
R1 干净的 JSON,角度对,人味够 放行
R2 JSON 前后夹着解释文字 兜底解析,但内容太短、人味不够 → 打回重写
R3 格式没问题,但形态和角度都跑偏了 打回(这是最该警惕的一种)
R4 结构破损(缺右括号) 解析失败 → 重发请求(不是打回重写)
# -*- coding: utf-8 -*-
"""5.4.1 Demo:取稿验收器 —— 从多轮对话的最后一段里【取出稿子,并当场验收】(纯标准库)"""
import json
import re
import statistics

# 任务卡(5.3.1 产出、5.3.2 写进提示词的那张卡)
TASK = {"form": "回答", "angle": "成本预算", "words_min": 300, "words_max": 600}

# 模拟四种子窗口返回(都在【多轮对话的最后一段】里)
REPLIES = {
    "R1": '{"form":"回答","qurl":"https://www.zhihu.com/question/123456","angle":"成本预算",'
          '"body":"先说预算,我给三档,都是我实际花过的数。第一档二百元以内:窄边换鞋凳一百二,'
          '壁挂钥匙盘三十,折叠收纳箱五十,加起来两百,我家玄关就这一档。第二档五百元左右:'
          '把换鞋凳换成一米二宽的鞋柜,多一个抽屉放鞋油和伞,约四百,再配一个可视收纳箱八十。'
          '第三档一千五左右:做嵌入式玄关柜,柜体一千二,加装感应灯带三百。说实话我觉得没必要上第三档。'
          '我家门后只有二十厘米,一体式根本塞不进去,问过两家定制都说做不了。'
          '真要花钱,我建议先花二十块买个卷尺,量准三个尺寸:门后到墙、玄关进深、开门半径。'
          '量完你会发现,很多方案自己就排除了。我一开始就是没量,买了个三十五厘米的鞋凳,开门撞脚,退了。'
          '后来我把这三档记在手机备忘录里,买一样划一笔,基本不会超。",'
          '"topics":["收纳","家居"],"ai_assisted":true}',

    "R2": '好的,我按成本预算这个角度写了一版:\n'
          '{"form":"回答","qurl":"https://www.zhihu.com/question/123456","angle":"成本预算",'
          '"body":"收纳预算因人而异,需要根据实际情况确定。合理的预算规划能够帮助您更高效地完成收纳改造。'
          '建议先明确需求,再选择适合的工具,最后根据预算范围做出决策。这样可以避免不必要的浪费。",'
          '"topics":["收纳"],"ai_assisted":true}\n'
          '需要我再补充一些具体价位吗?',

    "R3": '{"form":"文章","qurl":"","angle":"原理科普",'
          '"body":"家庭收纳是一项需要系统规划的工作。合理的收纳能够显著提升居住体验。'
          '首先,我们需要明确收纳的基本原则。分类是收纳的基础。合理的分类能够让物品各归其位。'
          '其次,我们要选择适合的收纳工具。收纳盒是常见的选择。不同尺寸的收纳盒适用于不同场景。",'
          '"topics":["收纳"],"ai_assisted":true}',

    "R4": '{"form":"回答","qurl":"https://www.zhihu.com/question/123456","angle":"成本预算",'
          '"body":"这一段我写到一半被打断了,字段也没闭合。","topics":["收纳"',
}

# ---- 解析 ----
def parse(raw):
    """三档:direct / fallback / fail"""
    try:
        return json.loads(raw), "direct"
    except Exception:
        pass
    m = re.search(r"\{.*\}", raw, re.S)
    if m:
        try:
            return json.loads(m.group(0)), "fallback"
        except Exception:
            pass
    return None, "fail"

# ---- 人味快检(沿用 5.3.2 的六项) ----
CONNECT = ["首先", "其次", "再次", "最后", "综上所述", "总而言之", "总的来说", "由此可见"]
STANCE = ["我觉得", "我认为", "我建议", "我不建议", "说实话", "坦白说", "我个人", "我劝", "我的经验是"]
HESITATE = ["可能", "大概", "不一定", "看情况", "因人而异", "说不准", "未必", "再说吧", "就算了"]
SELFACT = [r"我家", r"我(?!们)[^,。;:!?\n]{0,6}?(?:买|试|换|退|装|用|踩|花|攒|量|清|扔|留|做|选|测|查|问)"]
DETAIL = [r"[\d一二三四五六七八九十百千万两半几]+\s*(?:厘米|毫米|米|寸|公分|平|元|块|天|分钟|小时|秒|个月|年|双|个|张|件)"]

def human_score(text):
    n = len(re.sub(r"\s+", "", text)) or 1
    ss = [len(p) for p in re.split(r"[。!?!?\n]", text) if len(p.strip()) > 1]
    sd = statistics.pstdev(ss) if len(ss) > 1 else 0
    hits = [
        sd >= 8,
        sum(text.count(w) for w in CONNECT) * 100.0 / n <= 1.0,
        sum(len(re.findall(p, text)) for p in DETAIL) >= 3,
        sum(text.count(w) for w in STANCE) >= 2,
        sum(text.count(w) for w in HESITATE) >= 1,
        sum(len(re.findall(p, text)) for p in SELFACT) >= 2,
    ]
    return sum(hits), 6

# ---- 验收 ----
print("=" * 74)
print("一、解析(取稿的第一道关:这段返回里到底有没有一份能用的稿子)")
print("=" * 74)
parsed = {}
for k, raw in REPLIES.items():
    d, how = parse(raw)
    parsed[k] = d
    tag = {"direct": "纯 JSON,直接解析 ✔", "fallback": "夹了解释文字,正则兜底 ✔", "fail": "结构破损,解析失败 ✘"}[how]
    print("  [%s] %s" % (k, tag))

print()
print("=" * 74)
print("二、验收(取到了稿子,还得问它是不是【任务卡要的那一份】)")
print("=" * 74)
verdicts = {}
for k, d in parsed.items():
    if d is None:
        print()
        print("  [%s] 解析失败 —— 不验收,直接重发请求(并记录一次失败)" % k)
        verdicts[k] = "重发"
        continue
    bad, warn = [], []
    if d.get("form") != TASK["form"]:
        bad.append("形态是【%s】,任务卡要的是【%s】" % (d.get("form"), TASK["form"]))
    if d.get("form") == "回答" and not d.get("qurl"):
        bad.append("形态是回答却没有问题链接")
    if d.get("angle") != TASK["angle"]:
        bad.append("角度跑偏:写成【%s】,任务卡要的是【%s】" % (d.get("angle"), TASK["angle"]))
    body = d.get("body", "")
    wn = len(re.sub(r"\s+", "", body))
    if wn < TASK["words_min"]:
        bad.append("正文 %d 字,低于下限 %d" % (wn, TASK["words_min"]))
    elif wn > TASK["words_max"]:
        warn.append("正文 %d 字,超过上限 %d(提示)" % (wn, TASK["words_max"]))
    hs, ht = human_score(body)
    if hs < 4:
        bad.append("人味仅 %d/%d —— 这一条要回 5.3.2 重写" % (hs, ht))
    elif hs < 6:
        warn.append("人味 %d/%d —— 能进下一步,但记得在【补人味】那一格补足" % (hs, ht))

    print()
    print("  [%s] 形态=%s 角度=%s 正文=%d 字 人味=%d/%d"
          % (k, d.get("form"), d.get("angle"), wn, hs, ht))
    for b in bad:
        print("        ✗ %s" % b)
    for w in warn:
        print("        ! %s" % w)
    if bad:
        verdicts[k] = "打回"
    else:
        verdicts[k] = "放行"

print()
print("=" * 74)
print("三、汇总")
print("=" * 74)
for k in REPLIES:
    print("  [%s] → %s" % (k, verdicts[k]))
go = [k for k, v in verdicts.items() if v == "放行"]
print()
print("  放行进【补人味】的:%s" % ("、".join(go) if go else "(无)"))

实际运行输出(真跑,不是示意):

==========================================================================
一、解析(取稿的第一道关:这段返回里到底有没有一份能用的稿子)
==========================================================================
  [R1] 纯 JSON,直接解析 ✔
  [R2] 夹了解释文字,正则兜底 ✔
  [R3] 纯 JSON,直接解析 ✔
  [R4] 结构破损,解析失败 ✘

==========================================================================
二、验收(取到了稿子,还得问它是不是【任务卡要的那一份】)
==========================================================================

  [R1] 形态=回答 角度=成本预算 正文=314 字 人味=5/6
        ! 人味 5/6 —— 能进下一步,但记得在【补人味】那一格补足

  [R2] 形态=回答 角度=成本预算 正文=86 字 人味=1/6
        ✗ 正文 86 字,低于下限 300
        ✗ 人味仅 1/6 —— 这一条要回 5.3.2 重写

  [R3] 形态=文章 角度=原理科普 正文=116 字 人味=0/6
        ✗ 形态是【文章】,任务卡要的是【回答】
        ✗ 角度跑偏:写成【原理科普】,任务卡要的是【成本预算】
        ✗ 正文 116 字,低于下限 300
        ✗ 人味仅 0/6 —— 这一条要回 5.3.2 重写

  [R4] 解析失败 —— 不验收,直接重发请求(并记录一次失败)

==========================================================================
三、汇总
==========================================================================
  [R1] → 放行
  [R2] → 打回
  [R3] → 打回
  [R4] → 重发

  放行进【补人味】的:R1

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

  1. R4 的处理方式是本节最容易被做错的一处:解析失败要【重发】,不是【打回重写】。 这两件事看起来都是【不行,重来】,成本差得很远:重发只是把请求再送一次(稿子可能只是生成到一半断了、或者页面刷新了);打回重写则是让模型从头再写一遍——如果原文其实没问题、只是没传完,这一下就白花一次生成。所以【解析失败】必须单独一档。

  2. R3 才是本章最该警惕的一种失败——它的格式完全正确,措辞也正常,内容甚至挺像样。问题在:形态写成了【文章】(要的是回答)、角度写成了【原理科普】(要的是成本预算)。 如果没有【角度核对】这一条,它会一路往下走——一直走到发布前才发现【这不是我要的角度】,那时候 5.2 任务表后面的几格全白填了。 这就是本节反复强调的:验收必须比对着任务卡做,不能只查格式。

  3. R2 演示了什么是【格式能救、内容不能救】:兜底解析把它救回来了(前面的解释文字被正确忽略),但它本身太短(86 字)、人味只有 1/6——所以救回来之后照样打回。 这一条与 4.4.2 那句结论完全一致:兜底只负责【让它能被读】,不负责【让它能过】。

  4. 人味检查在取稿这一步就能算出来,而且 R1 的人味是 5/6——只差一项就满分,脚本给的是【提示】而不是【打回】。 这个分档很有用:5/6 的稿子不用重写,人在【补人味】那一格顺手补一项就行(5.3.2 说过:补内容的成本很低)。 如果把它设成【必须 6/6 才放行】,会让大量其实够用的稿子被退回重生成——那是把【严格】用成了【浪费】。

  5. 四份返回里只有一份放行——这不是脚本太严,这是真实比例。 一次生成的合格率本来就不高(尤其当提示词里带了那么多约束)。而这恰好说明了【验收】这道关存在的意义:它把不合格的挡在【最便宜】的位置上。 一次重发、一次重写的成本,远低于【走到发布前才发现不对】。

📝 顺手看一眼本节与 3.4.2 的关系

两节的解析逻辑是同一套(直接解析 → 正则兜底 → 失败),本节多出来的是第二关【验收】。为什么第三章不需要? 因为第三章的任务很简单(一篇文章、一个主题),模型没什么可跑偏的;而本章的任务卡是【带角度和缺口约束的】——约束越多,跑偏的可能性越大。 一条通用规律:约束越细的任务,验收就要越严。 因为约束是【上游的结论】,而模型并不知道那个结论有多重要。

五、⚠️ 三条工程提醒

这三条都是【跑起来才会遇到】的问题。

提醒一:等待策略不要写死 sleep

生成时间是不确定的(几百字和两千字差很远、高峰期和深夜差很远)。写死 sleep(30) 有两种后果:太短则取到半截稿,太长则每次白等。

正确做法是【轮询一个结束标志】:

可行的标志 说明
页面上【停止生成】按钮消失 最直接
正文区域出现结束标点 粗略但好用
返回文本里出现了 JSON 的右花括号 本节的场景最适用(稿子以 } 结尾)
超时上限 兜底:超过 N 秒都没结束,判定失败 → 走重发

提醒二:会话要隔离,每篇一个新会话

这一条与 4.4.3 的【墨滴会记住上一次的内容】是同一类坑,但后果更严重。

如果上一篇的上下文还在,模型可能: - 把上一篇的题材混进这一篇; - 把上一篇的【角度】带过来(这正好制造了 R3 那种跑偏); - 或者回一句【和刚才那篇一样处理吗】——而你解析不了这句话。

做法:每篇开一个新会话;或者在同一个会话里明确说清【换一个任务,忽略上面的内容】。

提醒三:失败的原始返回一定要存下来

因为失败的原因往往不在报错里,在原始返回里。

R4 那种缺右括号,你看解析器的报错只能看到【JSON 解析失败】——但你要知道的是【它到底写完了没有】。存下原文,你才能判断:这是【没生成完】(重发),还是【格式就是错的】(改提示词)。

这条和 5.2 的【要留痕】是同一条原则,只不过5.2 留的是【业务痕迹】(哪篇走到哪一步),这里留的是【工程痕迹】(失败时原文长什么样)。

30 秒实验

把 Demo 里 R3 的 angle 从 原理科普 改成 成本预算,再跑一次。

会发生什么:

结论:验收器只检查【字段】,不检查【这条路是否正当】。 一份跑偏后被人工改回来的稿子,和一份一次写对的稿子,在下游看来没有区别——这正是为什么【验收要卡在取稿这一步】:越早纠正,越接近源头。

常见误区

  1. 【解析失败就打回重写】——错,这一步很贵。 解析失败要【重发】。 两者的成本差得很远:重发只是把请求再送一次(稿子可能只是生成到一半断了),打回重写是让模型从头再写一遍。所以解析失败必须单独设一档。
  2. 【只要能解析出 JSON,就算取稿成功】——错,那只过了第一关。 格式正确、内容像样的稿子,形态和角度都可能跑偏(R3 就是),而这一类比格式错误更危险——因为你看不出来,它会一路走到发布前,让前面几格全白做。
  3. 【任务卡可以理解一下再发,让模型更好懂】——错,一个字都别改。 任务卡里的每个字段都是对标算出来的结论(角度来自缺口、数字要求来自人味检查、形态决定下游操作)。你顺手改一个词,可能就改回了那个挤了 3 篇的红海角度。 任务卡是上游的结论,不是提示词草稿。
  4. 【等一会儿再读就行,固定 sleep 30 秒】——错,会两头出错。 生成时间不确定:太短取到半截稿(然后就白白走一次【重发】),太长每次白等。 正确做法是轮询一个【结束标志】(停止生成按钮消失、正文出现结束标点、返回里出现 }),并给一个超时上限兜底。
  5. 【每次都复用一个会话更省事】——错,这是 R3 那种跑偏的常见成因。 上下文不隔离,上一篇的题材和角度会串进这一篇——而【角度被带过来】正好就是本节最警惕的那种错误。 每篇开新会话,或者在同一个会话里明确说清【换任务、忽略上面的内容】。
自测三问(点开对答案) 1. **为什么【解析失败】必须和【验收不通过】分开成两档?** 因为**成本完全不同**:**解析失败走【重发】**——只是把请求再送一次(稿子可能只是生成到一半断了、页面刷新了);**验收不通过走【打回重写】**——让模型从头再生成一遍。 **如果把两者都当【重来】,那么一篇其实写好了、只是没传完的稿子,会被白白重写一次。** 所以解析结果要分三档(直接解析/正则兜底/失败),**失败单独走一条路。** 2. **`R3` 那种失败为什么比格式错误更危险?** 因为**它的格式完全正确、措辞也正常、内容甚至挺像样**——**你看不出来它有问题**。它的问题在**形态(文章 vs 回答)和角度(原理科普 vs 成本预算)**,而这两个字段是**来自任务卡的约束**。 **危险在于它会被当成合格稿一路往下走**,一直到发布前才发现【这不是我要的角度】——**那时候 5.2 任务表后面几格全白填了。** 所以**验收必须比对着任务卡做,不能只查格式。** 3. **为什么说【约束越细的任务,验收就要越严】?** 因为**约束是【上游的结论】,而模型并不知道那个结论有多重要**。5.3.1 算出【成本预算这个角度没人写】,这是个**带判断的结论**;模型看到的只是【写一篇关于成本预算的回答】,**它对【为什么必须是这个角度】毫无概念**,所以**一旦它觉得另一个角度更好写,就会自己换掉**(`R3` 就是)。 **对比第三章**:3.4.2 的任务很单纯(一篇文章、一个主题),**没什么可跑偏的,所以不需要第二关验收。** 本章的卡带了角度和缺口,**约束越多,跑偏的可能越大,验收就必须越严。**

相关笔记