流水线编排:从"一步"到"一链" — Pipeline模式让Agent的复杂任务变得可维护

179 阅读17分钟

先说结论

Agent 最怕什么?一坨代码干所有事,改一处牵一发动全身。

生成一篇文章,要做选题、写标题、写正文、排版、质检、存储、导出 — 7 件事。如果全写在一个函数里,改排版逻辑要在一坨代码里找半天,改质检规则可能碰坏生成逻辑,加一个"生成脚本"功能要重读整个函数。

解法:Pipeline 模式 — 把复杂任务分解为有序步骤,数据在步骤间流转,每个步骤只做一件事。

在 self-media-agent 项目里,pipeline/runner.py 就是这条流水线的总指挥。它不写具体逻辑,只负责编排 — 调用各模块、传递数据、处理异常。每个步骤的真正逻辑在各自的模块里(标题生成在 content/title.py,排版在 content/formatter.py,质检在 quality/orchestrator.py)。

一句话:Pipeline 不是设计模式书里的概念,是真实项目从"改不动"到"改得动"的血泪教训。

一、从"一坨代码"到"一条流水线"

反面教材:所有逻辑写在一个函数里

# ❌ 早期版本:一个函数干所有事
async def generate_article(persona_id, topic):
    # 查人设
    persona = get_persona(persona_id)
    
    # 生成标题
    titles = await llm.generate("生成标题", f"选题:{topic}")
    title = json.loads(titles)[0]["title"]
    
    # 生成正文
    body = await llm.generate("生成正文", f"标题:{title}")
    
    # 排版:分段、加emoji、加粗小标题、加间距、加开头结尾模板
    paragraphs = body.split("\n\n")
    # ... 20行排版代码 ...
    formatted = "..."
    
    # 质检:敏感词、去重、口语化、逻辑检查
    # ... 30行质检代码 ...
    score = 0.85
    
    # 存储
    content = {"title": title, "body": formatted, "score": score}
    save_to_db(content)
    
    # 导出文件
    with open(f"output/{title}.md", "w") as f:
        f.write(f"# {title}\n\n{formatted}")
    
    return content

问题:

改什么要动哪里风险
改排版逻辑在50行代码里找到排版那20行可能碰坏质检逻辑
加"生成脚本"功能在函数里加 if/else 分支函数膨胀到100+行
换质检策略在函数里找到质检那30行替换可能碰坏存储逻辑
单独测试质检没法测 — 质检逻辑和生成逻辑耦合只能整体集成测试
某步失败要重试没有"步骤"概念,无法局部重试整个函数从头跑

正面教材:拆成 Pipeline

# ✅ PipelineRunner:只编排,不写具体逻辑
class PipelineRunner:
    async def _generate_single(self, persona, topic, output_dir):
        # 2a: 生成标题
        titles = await self.title_gen.generate(persona, topic)
        best_title = titles[0]
​
        # 2b: 生成正文/脚本
        raw_body = await self.body_gen.generate_text(persona, topic, best_title)
​
        # 2c: 排版
        formatted = self.formatter.format(persona, raw_body)
​
        # 2d: 四维质检
        quality_result = await self.quality_orchestrator.check_and_fix(persona, formatted)
​
        # 2e: 构建内容对象
        content = GeneratedContent(title=best_title, body=quality_result.final_content, ...)
​
        # 2f: 存储
        self.repo.store.save_content(content)
​
        # 2g: 导出文件
        await self._export(content, output_dir)
​
        return content
python

每个步骤都是一行调用,具体逻辑在各自模块里。改排版?去 formatter.py。改质检?去 orchestrator.py。加脚本生成?在 2b 加一个 if 分支,不影响其他步骤。

Pipeline 的本质:把"一坨"变成"一链",每环独立可替换、可测试、可复用,其中包含了模块化的思想。

二、PipelineRunner:主流程编排

初始化:组装所有模块

pipeline/runner.py 的 __init__ 方法,负责把所有模块实例化并注入依赖:

class PipelineRunner:
    """V2 主流程:热点选题 → 选题 → 标题 → 正文 → 排版 → 四维质检 → 输出"""
​
    def __init__(self, config: AppConfig, repo: Repository, cache=None):
        self.config = config
        self.repo = repo
        self.cache = cache or get_cache()
​
        # 初始化各模块
        self.llm = LLMClient(
            base_url=config.llm.base_url,
            api_key=config.llm.api_key,
            model=config.llm.model,
        )
        self.prompt_mgr = PromptManager()
        self.persona_mgr = PersonaManager(repo)
        self.topic_pool = TopicPool(repo)
        self.topic_gen = TopicGenerator(self.llm, self.prompt_mgr)
        self.title_gen = TitleGenerator(self.llm, self.prompt_mgr)
        self.body_gen = BodyGenerator(self.llm, self.prompt_mgr)
        self.script_gen = ScriptGenerator(self.llm, self.prompt_mgr)
        self.formatter = Formatter()
​
        # V2: 四维质检编排器
        self.quality_orchestrator = QualityOrchestrator(
            sensitive_filter=SensitiveFilter(
                sensitive_words_file=config.quality.sensitive_words_file,
            ),
        )

注意依赖注入的方向:

config ──→ LLMClient ──→ TopicGenerator ──┐
                              TitleGenerator ──┤
                              BodyGenerator  ──┤──→ PipelineRunner
                              ScriptGenerator ─┤
repo ──→ Repository ──────────────────────────┤
config.quality ──→ QualityOrchestrator ───────┘

PipelineRunner 不直接 new 配置,而是接收外部传入的 config 和 repo。各生成器接收 llm 和 prompt_mgr,不自己创建。依赖从外向内注入,内层不知道外层的存在 — 这是可测试的基础。

run():两级流水线

run() 方法是流水线的入口,分两个层级:

async def run(
    self,
    persona_id: str,
    topics: Optional[list[str]] = None,
    topic_count: int = 10,
    use_topics_as_titles: bool = False,
    output_dir: str = "data/output",
) -> list[GeneratedContent]:
    # ━━━ Step 1: 获取/生成选题 ━━━
    persona = self.persona_mgr.get(persona_id)
    if not persona:
        raise ValueError(f"人设不存在: {persona_id}")
​
    if topics:
        # 手动选题
        topic_list = [
            Topic(persona_id=persona_id, title=t, source=TopicSource.MANUAL)
            for t in topics
        ]
    else:
        # 自动生成选题
        topic_list = await self.topic_gen.generate_topics(persona, topic_count)
​
    for t in topic_list:
        self.topic_pool.add(t)
​
    # ━━━ Step 2: 批量生成内容(每个选题走一遍完整流水线) ━━━
    results = []
    for i, topic in enumerate(topic_list):
        try:
            content = await self._generate_single(persona, topic, output_dir)
            results.append(content)
            self.topic_pool.mark_used(topic.id)
        except Exception as e:
            logger.error(f"生成失败: {topic.title}, 错误: {e}")
            continue  # ← 关键:单篇失败不影响其他
​
    return results

两级结构:

run()                           ← 外层:选题 → 批量循环
├── Step 1: 获取/生成选题
└── Step 2: for topic in topics
    └── _generate_single()      ← 内层:单篇的完整流水线
        ├── 2a: 生成标题
        ├── 2b: 生成正文/脚本
        ├── 2c: 排版
        ├── 2d: 四维质检
        ├── 2e: 构建内容对象
        ├── 2f: 存储
        └── 2g: 导出文件

外层管批量,内层管单篇。 外层循环的 try/except + continue 是关键 — 一篇失败不会让整个批量任务崩溃。

三、_generate_single:单篇流水线的 7 个步骤

这是流水线的核心 — 一篇文章从选题到成品的完整旅程:

async def _generate_single(
    self, persona: PersonaConfig, topic: Topic, output_dir: str,
    use_as_title: bool = False,
) -> GeneratedContent:
    # ━━━ 2a: 生成标题 ━━━
    if use_as_title and topic.source == TopicSource.MANUAL:
        best_title = topic.title                    # 手动选题直接用作标题
    else:
        titles = await self.title_gen.generate(persona, topic)
        best_title = titles[0] if titles else topic.title
​
    # ━━━ 2b: 生成正文/脚本 ━━━
    if persona.content_format == "short_video":
        script = await self.script_gen.generate_script(persona, topic, best_title)
        raw_body = script.narration
        content_type = ContentType.SCRIPT
        script_narration = script.narration
        script_camera_hints = script.camera_hints
        script_subtitle_highlights = script.subtitle_highlights
    else:
        raw_body = await self.body_gen.generate_text(persona, topic, best_title)
        content_type = ContentType.TEXT
        script_narration = None
        script_camera_hints = []
        script_subtitle_highlights = []
​
    # ━━━ 2c: 排版 ━━━
    formatted = self.formatter.format(persona, raw_body, topic_title=topic.title)
​
    # ━━━ 2d: V2 四维质检 ━━━
    existing_contents = [
        c.body for c in self.repo.store.list_contents()
        if c.persona_id == persona.id
    ]
    quality_result = await self.quality_orchestrator.check_and_fix(
        persona=persona,
        content=formatted,
        auto_fix=self.config.quality.auto_fix,
        existing_contents=existing_contents,
        enable_dedup=self.config.quality.dedup_enabled,
        enable_colloquial=self.config.quality.colloquial_enabled,
        enable_logic=self.config.quality.logic_check_enabled,
    )
    formatted = quality_result.final_content
    quality_score = quality_result.total_score
​
    # ━━━ 2e: 构建 GeneratedContent ━━━
    report = QualityReport(
        passed=quality_score >= 0.5,
        score=quality_score,
        details={"checks_count": len(quality_result.checks)},
    )
    content = GeneratedContent(
        persona_id=persona.id,
        topic_id=topic.id,
        category=topic.category,
        title=best_title,
        body=formatted,
        content_type=content_type,
        script_narration=script_narration,
        script_camera_hints=script_camera_hints,
        script_subtitle_highlights=script_subtitle_highlights,
        quality_score=quality_score,
        quality_report=report,
    )
    content.compute_word_count()
​
    # ━━━ 2f: 存入内存 + 可选落盘 ━━━
    self.repo.store.save_content(content)
    self.repo._maybe_persist()
​
    # ━━━ 2g: 导出文件 ━━━
    await self._export(content, output_dir)
​
    return content

7 个步骤,每个只做一件事:

步骤做什么输入输出对应模块
2a生成标题persona + topicbest_title: strcontent/title.py
2b生成正文/脚本persona + topic + titleraw_body: strcontent/body.py 或 content/script.py
2c排版persona + raw_bodyformatted: strcontent/formatter.py
2d四维质检persona + formattedquality_resultquality/orchestrator.py
2e构建内容对象所有上面的结果GeneratedContentcontent/schema.py
2f存储content无(副作用)storage/repository.py
2g导出文件content + output_dir无(副作用)_export()

注意 2b 的分支:content_format == "short_video" 走脚本生成,否则走正文生成。这是流水线里唯一的条件分支 — 不同内容形式走不同生成器,但后续步骤(排版、质检、存储)完全一样。分支只在该步骤内部,不污染整条流水线。

四、数据在步骤间流转:流水线的血液

Pipeline 的精髓不是步骤拆分,而是数据在步骤间流转。每一步的输出是下一步的输入,数据像血液一样流过整条管线。

数据流图

persona ──────────────────────────────────────────────────────┐
topic ────────────────────────────────────────────────────────┤
                                                              │
  ┌─── 2a: 标题生成 ───┐                                      │
  │  输入: persona, topic                                     │
  │  输出: best_title ─────────────────────────┐             │
  └────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2b: 正文生成 ───┐                                      │
  │  输入: persona, topic, best_title          │             │
  │  输出: raw_body ───────────────────────────┐             │
  └─────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2c: 排版 ────────┐                                      │
  │  输入: persona, raw_body                   │             │
  │  输出: formatted ──────────────────────────┐             │
  └─────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2d: 四维质检 ───┐                                      │
  │  输入: persona, formatted, existing_contents│             │
  │  输出: quality_result ─────────────────────┐             │
  │        (final_content, total_score)         │             │
  └─────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2e: 构建对象 ───┐                                      │
  │  输入: 所有上面的结果                      │             │
  │  输出: GeneratedContent ───────────────────┐             │
  └─────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2f: 存储 ────────┐                                      │
  │  输入: content                              │             │
  │  副作用: 写入 repo                          │             │
  └─────────────────────────────────────────────┘             │
                                                              │
  ┌─── 2g: 导出文件 ───┐                                      │
  │  输入: content, output_dir                  │             │
  │  副作用: 写入 .md 文件                      │             │
  └─────────────────────────────────────────────┘             │
                                                              │
  return content ◄────────────────────────────────────────────┘

关键观察:

  1. persona 和 topic 贯穿始终 — 它们是流水线的"常量",每一步都可能用到
  2. 每步产出一个新变量 — best_title → raw_body → formatted → quality_result → content,数据逐步丰富
  3. 2e 是汇聚点 — 前面所有步骤的输出在这里汇成一个 GeneratedContent 对象
  4. 2f/2g 是副作用 — 不产出新数据,只把 content 写到外部(数据库 + 文件)

为什么不直接传 dict?

# ❌ 用 dict 在步骤间传数据
context = {"persona": persona, "topic": topic}
context = step_2a(context)  # context["title"] = ...
context = step_2b(context)  # context["body"] = ...
context = step_2c(context)  # context["formatted"] = ...

问题:dict 没有类型约束,context["title"] 拼成 context["titel"] 不会报错,直到运行时 KeyError。

# ✅ 用明确变量 + Pydantic 模型
best_title = await self.title_gen.generate(persona, topic)  # str
raw_body = await self.body_gen.generate_text(persona, topic, best_title)  # str
formatted = self.formatter.format(persona, raw_body)  # str
content = GeneratedContent(title=best_title, body=formatted, ...)  # Pydantic 模型

明确变量有类型、有自动补全、拼错会报错。 流水线的步骤间传数据,用变量比用 dict 更安全。(第3篇讲的 Pydantic Schema 在这里发挥作用 — 最终汇聚成 GeneratedContent 时有完整校验。)

五、嵌套流水线:质检和排版也是 Pipeline

Pipeline 不只用在主流程 — 项目里质检和排版各自也是一条小流水线。

质检流水线:QualityOrchestrator

quality/orchestrator.py 是一条 4 步流水线,每步 check → 可选 fix → 下一步:

class QualityOrchestrator:
    """质检编排:依次执行四个质检维度

    执行顺序:
    1. 合规检测(敏感词过滤)
    2. 去同质化(相似度检测)
    3. 口语化优化
    4. 逻辑校验

    每个维度 check → 可选 fix → 下一个维度
    """

    async def check_and_fix(self, persona, content, auto_fix=True, ...):
        result = QualityResult(original=content, final_content=content)
        current = content

        # 1. 合规检测
        report = self.sensitive_filter.check(persona, current)
        if not report.passed and auto_fix:
            current = await self.sensitive_filter.auto_fix(persona, current, report)
            report = self.sensitive_filter.check(persona, current)
        result.checks.append({"name": "合规检测", "passed": report.passed, ...})

        # 2. 去同质化
        if enable_dedup:
            dedup_report = await self.dedup_checker.check(persona, current, existing_contents)
            if not dedup_report.passed and auto_fix:
                current = await self.dedup_checker.fix(persona, current, dedup_report)
            result.checks.append({"name": "去同质化", ...})

        # 3. 口语化优化
        if enable_colloquial:
            colloquial_report = await self.colloquial_optimizer.check(persona, current)
            if not colloquial_report.passed and auto_fix:
                current = await self.colloquial_optimizer.fix(persona, current, colloquial_report)
            result.checks.append({"name": "口语化优化", ...})

        # 4. 逻辑校验
        if enable_logic:
            logic_report = await self.logic_checker.check(persona, current)
            if not logic_report.passed and auto_fix:
                current = await self.logic_checker.fix(persona, current, logic_report)
            result.checks.append({"name": "逻辑校验", ...})

        # 汇总
        result.final_content = current
        result.passed = all(c.get("passed", True) for c in result.checks)
        scores = [c.get("score", 1.0) for c in result.checks]
        result.total_score = sum(scores) / len(scores) if scores else 1.0

        return result

质检流水线的数据流:

content(原始排版后内容)
    ↓
[1. 合规检测] check → fix → check    → current(敏感词已替换)
    ↓
[2. 去同质化] check → fix             → current(相似段落已改写)
    ↓
[3. 口语化]   check → fix             → current(AI书面语已优化)
    ↓
[4. 逻辑校验] check → fix             → current(逻辑问题已修复)
    ↓
final_content + total_score

关键设计:current 变量在步骤间流转 — 每个质检维度可能修改内容,修改后的 current 传给下一个维度。不是并行检查同一份内容,是串行检查逐步改进后的内容。

为什么串行而不是并行?

串行并行
敏感词替换后再查去同质化4个维度同时查同一份内容
修复有顺序:先合规(硬性)再优化(软性)无法控制修复顺序
每步看到的是上一步改进后的结果各自修复可能冲突
✅ 项目采用❌ 修复结果可能互相覆盖

排版流水线:Formatter

content/formatter.py 的 format() 方法,是一条 6 步流水线:

class Formatter:
    def format(self, persona, raw_content, topic_title=""):
        content = raw_content
        is_article = self._is_article_format(persona.content_format)

        # 1. 分段(过长段落拆分)
        max_chars = persona.paragraph_max_chars * 2 if is_article else persona.paragraph_max_chars
        content = self._split_paragraphs(content, max_chars)

        # 2. 添加 emoji(公众号图文不加)
        if not is_article:
            content = self._add_emojis(content, persona.emoji_frequency, persona.niche)

        # 3. 小标题处理(公众号用 ## 标题,小红书用加粗)
        if is_article:
            content = self._heading_highlights(content)
        else:
            content = self._bold_highlights(content)

        # 4. 添加间距
        content = self._add_spacing(content, persona.line_spacing)

        # 5. 开头模板
        opening = self._render_template(persona.opening_template, topic_title)
        content = self._wrap_opening(content, opening)

        # 6. 结尾引导模板
        closing = self._render_template(persona.closing_template, topic_title)
        content = self._wrap_closing(content, closing)

        return content

6 步排版流水线:

raw_content(LLM 原始输出)
    ↓ [1. 分段]      → 段落不超过 max_chars
    ↓ [2. 加 emoji]  → 按频率在段首加 emoji
    ↓ [3. 小标题]    → 短行加粗 或 ## 标记
    ↓ [4. 间距]      → 段落间 \n / \n\n / \n\n\n
    ↓ [5. 开头模板]  → 前面加开场白
    ↓ [6. 结尾模板]  → 后面加引导语
    ↓
formatted(可直接发布的成品)

Pipeline 无处不在 — 主流程是 Pipeline,质检是 Pipeline,排版也是 Pipeline。大 Pipeline 嵌套小 Pipeline,像 fractal 一样自相似。

六、错误处理:某步失败时中断还是降级?

流水线的错误处理是工程设计的核心问题 — 某一步失败了,是中断整条流水线,还是降级跳过?

策略1:单篇失败 → 跳过,继续下一篇

run() 方法的外层循环:

for i, topic in enumerate(topic_list):
    try:
        content = await self._generate_single(persona, topic, output_dir)
        results.append(content)
        self.topic_pool.mark_used(topic.id)
    except Exception as e:
        logger.error(f"生成失败: {topic.title}, 错误: {e}")
        continue  # ← 跳过这篇,继续下一篇

为什么跳过而不是中断? 批量生成 10 篇,第 3 篇因为 LLM 超时失败了 — 用户期望拿到另外 9 篇,而不是全部白费。

策略2:质检维度 → 降级,跳过该维度

QualityOrchestrator 的每个维度都有 enable_xxx 开关:

# 去同质化
if enable_dedup:
    dedup_report = await self.dedup_checker.check(persona, current, existing_contents)
    if not dedup_report.passed and auto_fix:
        current = await self.dedup_checker.fix(persona, current, dedup_report)
    result.checks.append({"name": "去同质化", ...})
else:
    result.checks.append({"name": "去同质化", "passed": True, "score": 1.0, "skipped": True})

enable_logic=False(默认关闭逻辑校验) — 逻辑校验需要调 LLM,慢且不稳定,默认关掉。关掉不是不记录,而是记一条 skipped: True 的占位结果,保持 4 个检查项的结构不变。

策略3:LLM 结构化输出失败 → 降级为纯文本解析

第3篇讲过的降级方案,在流水线里体现为:

# topic/generator.py
try:
    result = await self.llm.generate_json(system_prompt, user_prompt, schema=TopicList)
    topics = [Topic(...) for item in result.topics]
except Exception as e:
    logger.warning(f"LLM 结构化选题生成失败,尝试文本解析: {e}")
    raw = await self.llm.generate(system_prompt=system_prompt, user_prompt=user_prompt)
    topics = self._parse_topics_from_text(raw, persona.id)  # ← 降级

三种策略的对比

策略适用场景代价项目中的例子
中断致命错误,无法继续后续全部不执行人设不存在 → raise ValueError
跳过某项独立任务失败该项无结果,其他正常单篇生成失败 → continue
降级高级功能失败,有备选方案质量降低但不崩溃JSON 解析失败 → 纯文本解析

原则:越靠近流水线入口越倾向中断(人设都没有,没法干),越靠近出口越倾向降级(质检不过也要输出,只是分数低)。

七、异步任务引擎:流水线的并发调度

流水线本身是串行的(一步接一步),但多条流水线可以并行 — 批量生成 10 篇文章,可以并发跑 10 条流水线。

TaskEngine:进程内异步任务调度

task/engine.py 实现了一个轻量任务引擎,替代 Celery + Redis:

class TaskEngine:
    """进程内异步任务引擎 — 替代 Celery + Redis

    特性:
    - 基于 asyncio 的并发任务调度
    - 任务状态追踪:PENDING → RUNNING → SUCCESS/FAILED/CANCELLED
    - 批量任务支持进度更新
    - 最大并发数控制
    - 零外部依赖
    """

    def __init__(self, max_concurrent: int = 4):
        self.max_concurrent = max_concurrent
        self._tasks: dict[str, TaskInfo] = {}
        self._semaphore = asyncio.Semaphore(max_concurrent)  # ← 并发控制

任务状态流转:

PENDING → RUNNING → SUCCESS
                  → FAILED
                  → CANCELLED

submit:提交单个任务

async def submit(self, name: str, coro_func: Callable, *args, **kwargs) -> str:
    task_id = uuid.uuid4().hex[:12]
    info = TaskInfo(id=task_id, name=name)
    self._tasks[task_id] = info

    async def _run() -> None:
        async with self._semaphore:          # ← 并发限制
            info.status = TaskStatus.RUNNING
            try:
                result = await coro_func(*args, **kwargs)
                info.result = result
                info.status = TaskStatus.SUCCESS
            except asyncio.CancelledError:
                info.status = TaskStatus.CANCELLED
            except Exception as e:
                info.status = TaskStatus.FAILED
                info.error = str(e)
            finally:
                info.finished_at = datetime.now()

    info._async_task = asyncio.create_task(_run())
    return task_id

asyncio.Semaphore(4) — 最多 4 个任务同时跑。第 5 个提交后在 semaphore 处排队,等前面有任务完成才启动。不无限并发,避免打爆 LLM API 的 rate limit。

submit_batch:批量任务 + 进度更新

async def submit_batch(self, name: str, items: list, coro_func: Callable) -> str:
    async def _run_batch() -> list:
        async with self._semaphore:
            results = []
            total = len(items)
            for i, item in enumerate(items):
                # 检查取消信号
                if not self._running or (info._cancel_event and info._cancel_event.is_set()):
                    info.status = TaskStatus.CANCELLED
                    break
                try:
                    result = await coro_func(item, info)
                    results.append(result)
                except Exception as e:
                    results.append(None)  # ← 单项失败不中断批量
                # 更新进度
                info.progress = int((i + 1) / total * 100)  # ← 0~100
            return results

    info._async_task = asyncio.create_task(_run_batch())
    return task_id

批量任务的三个关键设计:

  1. 进度更新 — info.progress = int((i+1) / total * 100),前端可以实时显示 "3/10"
  2. 取消支持 — 检查 _cancel_event,用户可以中途取消
  3. 单项容错 — results.append(None),一项失败不中断整个批量

八、两种触发方式:CLI 同步 vs Web 异步

同一条流水线,有两种触发方式 — CLI 同步执行,Web 异步执行。

CLI:同步执行

cli/main.py 的 run 命令:

@cli.command()
@click.option("--persona", "-p", "persona_id", required=True)
@click.option("--topics", "-t", "topics_str")
@click.option("--count", "-n", default=10)
def run(ctx, persona_id, topics_str, auto, count, persist):
    config = ctx.obj["config"]
    repo = ctx.obj["repo"]

    runner = PipelineRunner(config=config, repo=repo)

    # 同步执行:asyncio.run() 阻塞等待全部完成
    results = asyncio.run(
        runner.run(persona_id=persona_id, topics=topics, topic_count=count)
    )

    console.print(f"✅ 内容生产完成!共 {len(results)} 篇")

CLI 的特点:asyncio.run() 阻塞当前进程,等所有文章生成完才返回。用户在终端等,适合少量生成。

Web:异步执行

api/routes/content.py 的生成接口:

@router.post("/generate", response_model=ContentGenerateResponse)
async def generate_content(req: ContentGenerateRequest):
    state = get_state()
    runner = PipelineRunner(config=state.config, repo=state.repo)

    async def _generate():
        results = await runner.run(
            persona_id=req.persona_id,
            topics=req.topics,
            topic_count=req.count,
        )
        return [r.model_dump(mode="json") for r in results]

    # 异步执行:提交到 TaskEngine,立即返回 task_id
    task_id = await state.task_engine.submit(
        name="内容生成",
        coro_func=_generate,
    )
    return ContentGenerateResponse(task_id=task_id)  # ← 立刻返回,不等完成

Web 的特点:把 runner.run() 包成协程提交到 TaskEngine,立刻返回 task_id,不阻塞 HTTP 响应。前端拿着 task_id 轮询 /api/task/{task_id} 查进度。

对比

CLI 同步Web 异步
执行方式asyncio.run() 阻塞task_engine.submit() 立即返回
用户等待终端等全部完成立刻拿到 task_id,前端轮询进度
适合场景少量生成、脚本调用批量生成、交互式使用
取消Ctrl+CDELETE /api/task/{task_id}
进度日志打印task.progress(0~100)

同一条流水线,两种触发方式,零代码重复 — 这是 Pipeline 模式的额外收益:编排和触发解耦。

九、Pipeline 模式的完整图景

把所有内容串起来:

                    ┌─────────────────────────────┐
                    │       触发层                  │
                    │  CLI (asyncio.run)           │
                    │  Web (TaskEngine.submit)     │
                    └──────────────┬──────────────┘
                                   │
                    ┌──────────────▼──────────────┐
                    │    PipelineRunner.run()       │
                    │    外层:选题 → 批量循环       │
                    └──────────────┬──────────────┘
                                   │
              ┌────────────────────┼────────────────────┐
              │  for topic in topics                     │
              ▼                                         │
    ┌─────────────────────────────────────────────────┐ │
    │        _generate_single()  内层流水线             │ │
    │                                                  │ │
    │  2a: title_gen.generate()    → best_title        │ │
    │  2b: body_gen.generate_text() → raw_body         │ │
    │  2c: formatter.format()      → formatted         │ │
    │  2d: quality_orchestrator    → quality_result    │ │
    │      └─ 嵌套流水线:合规→去同质化→口语化→逻辑    │ │
    │  2e: GeneratedContent(...)   → content           │ │
    │  2f: repo.save_content()     → 存储              │ │
    │  2g: _export()               → .md 文件          │ │
    └─────────────────────────────────────────────────┘ │
              │                                         │
              └─────────────────────────────────────────┘
                                   │
                    ┌──────────────▼──────────────┐
                    │    results: list[GeneratedContent] │
                    └─────────────────────────────┘

三层嵌套 Pipeline:

层级管什么步骤数
外层 run()选题 → 批量循环2 步
内层 _generate_single()标题→正文→排版→质检→构建→存储→导出7 步
质检 check_and_fix()合规→去同质化→口语化→逻辑4 步

Pipeline 嵌 Pipeline,像 fractal 一样自相似 — 不管放大到哪一层,都是"步骤拆分 + 数据流转 + 错误处理"的相同结构。

踩坑总结

坑根因修复
所有逻辑写在一个函数没有步骤拆分意识拆成 Pipeline,每步只做一件事
改排版碰坏质检步骤间耦合每步独立模块,通过变量传数据
加功能要改5个地方逻辑散落各处集中在 PipelineRunner 编排,模块各管各的
单篇失败全部白费外层循环没 try/excepttry/except + continue,跳过失败篇
质检维度并行修复冲突并行 check 同一份内容改成串行,current 在步骤间流转
并发打爆 LLM API无限并发Semaphore(4) 限制最大并发
CLI 和 Web 逻辑重复触发和编排耦合Pipeline 只管编排,触发方式各自实现
无法单独测试某步步骤耦合在函数里每步是独立模块,可单独 import 测试
进度无法追踪同步阻塞无进度概念Web 端用 TaskEngine,progress 实时更新

经验总结

  1. Pipeline 模式的本质是"分治" — 把复杂任务分解为有序步骤,每步只做一件事,数据在步骤间流转
  2. 编排和执行分离 — PipelineRunner 只负责调用和传数据,具体逻辑在各模块,改一处不牵全身
  3. 大 Pipeline 嵌小 Pipeline — 主流程、质检、排版都是 Pipeline,fractal 自相似结构,每一层都可独立理解
  4. 错误处理三策略:中断、跳过、降级 — 越靠近入口越倾向中断,越靠近出口越倾向降级
  5. 编排和触发解耦 — 同一条流水线,CLI 同步触发、Web 异步触发,零代码重复

下篇预告

下一篇讲 感知模块:Agent的眼睛和耳朵 — Agent 如何获取外部信息,三层降级策略(真实爬取 → 模拟数据 → LLM 动态生成),以及赛道"劳务派遣"抓到美妆热点的踩坑修复。