先说结论
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 + topic | best_title: str | content/title.py |
| 2b | 生成正文/脚本 | persona + topic + title | raw_body: str | content/body.py 或 content/script.py |
| 2c | 排版 | persona + raw_body | formatted: str | content/formatter.py |
| 2d | 四维质检 | persona + formatted | quality_result | quality/orchestrator.py |
| 2e | 构建内容对象 | 所有上面的结果 | GeneratedContent | content/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 ◄────────────────────────────────────────────┘
关键观察:
- persona 和 topic 贯穿始终 — 它们是流水线的"常量",每一步都可能用到
- 每步产出一个新变量 —
best_title→raw_body→formatted→quality_result→content,数据逐步丰富 - 2e 是汇聚点 — 前面所有步骤的输出在这里汇成一个
GeneratedContent对象 - 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
批量任务的三个关键设计:
- 进度更新 —
info.progress = int((i+1) / total * 100),前端可以实时显示 "3/10" - 取消支持 — 检查
_cancel_event,用户可以中途取消 - 单项容错 —
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+C | DELETE /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/except | try/except + continue,跳过失败篇 |
| 质检维度并行修复冲突 | 并行 check 同一份内容 | 改成串行,current 在步骤间流转 |
| 并发打爆 LLM API | 无限并发 | Semaphore(4) 限制最大并发 |
| CLI 和 Web 逻辑重复 | 触发和编排耦合 | Pipeline 只管编排,触发方式各自实现 |
| 无法单独测试某步 | 步骤耦合在函数里 | 每步是独立模块,可单独 import 测试 |
| 进度无法追踪 | 同步阻塞无进度概念 | Web 端用 TaskEngine,progress 实时更新 |
经验总结
- Pipeline 模式的本质是"分治" — 把复杂任务分解为有序步骤,每步只做一件事,数据在步骤间流转
- 编排和执行分离 —
PipelineRunner只负责调用和传数据,具体逻辑在各模块,改一处不牵全身 - 大 Pipeline 嵌小 Pipeline — 主流程、质检、排版都是 Pipeline,fractal 自相似结构,每一层都可独立理解
- 错误处理三策略:中断、跳过、降级 — 越靠近入口越倾向中断,越靠近出口越倾向降级
- 编排和触发解耦 — 同一条流水线,CLI 同步触发、Web 异步触发,零代码重复
下篇预告
下一篇讲 感知模块:Agent的眼睛和耳朵 — Agent 如何获取外部信息,三层降级策略(真实爬取 → 模拟数据 → LLM 动态生成),以及赛道"劳务派遣"抓到美妆热点的踩坑修复。