
网上关于AI 自动化单人日入过万的帖子很多真正把营销话术剥掉之后剩下的可复现部分其实只有一条工程流水线批量任务怎么组织、模型怎么调用、失败怎么重试、额度怎么控制、结果怎么校验、断点怎么续跑。收益部分因人、因平台规则而异本文不做任何承诺只把这条流水线从零到一拆开讲清楚。本文面向会一点 Python、想把 AI 调用做成稳定定时任务而不是手动点对话框的开发者读完你可以得到一套能直接跑起来的工程骨架配置文件、模型调用封装、并发调度、预算熔断、结果落盘、质量抽检和定时部署代码全部可复制。整体定位是一篇项目实战 工程经验型教程重点放在可靠性和成本控制上而不是教你写一句漂亮提示词就结束。1. 概念澄清AI 自动化流水线到底自动化了什么1.1 三个自动化层次别一上来就想全自动讨论AI 自动化之前需要先把自动化的层次分开否则很容易把一件基础设施工程误解成点一下按钮钱就来了。第一层是调用自动化指的是把人工在对话框里复制粘贴的动作变成程序里的 HTTP 请求这是最容易实现的一层一天就能跑通第二层是流程自动化指的是把任务输入、批量循环、结果落盘、失败重试、定时触发串成一条链这一层决定了你能不能连续跑一周不出问题第三层是决策自动化指的是让系统自己判断生成的内容是否合格、是否发布、是否要回滚这一层涉及质量评估和业务规则工程量和风险都最高。绝大多数所谓AI 副业复盘的帖子实际上只停留在第一层把控制台打印出来的一次成功响应当成可稳定复制的生产能力这是后续所有问题的根源。本文的实战部分会覆盖第一层和第二层也就是调用封装 流水线调度并在第 5 节给出第三层中可自动化部分的边界——哪些校验可以交给代码哪些必须留人工复核。把边界划清楚比盲目追全自动重要得多因为一次批量生成几百条低质内容清理成本往往高于慢慢做十条。1.2 一条可复用流水线由哪些模块组成把流水线拆成模块是为了让每一块都能单独测试、单独替换。参考典型的后端任务系统一条 AI 批量生成流水线通常包含六个部分配置层密钥、接口地址、模型名、超时、并发数、预算上限全部从环境变量或配置文件读取不写死在代码里。调用层统一封装模型 SDK向上只暴露一个chat(system, user)方法屏蔽不同服务商参数差异。调度层读取任务列表按并发度分发收集结果控制顺序写入。持久层结果以 JSONL 追加写入天然支持断点续跑中途崩了不用重头再来。校验层长度、关键词覆盖、重复度、结构合法性先机器筛一遍再交给人。成本层按 token 累计成本超过预算直接熔断避免一晚上跑飞账单。这六个模块里最容易被忽略的是持久层和成本层而它们恰恰是能不能跑第二周的关键。后面每节都会对应其中一个模块给出可运行代码读者可以按顺序抄也可以只取自己需要的那块。1.3 日入过万这类说法工程视角该怎么看从工程角度看任何单人日产过万的描述都需要拆成可验证的变量单条产出的耗时、单条产出的成本、单条被采用的概率、平台对批量内容的容忍度。这四个变量里只有前两个是技术能优化的后两个由业务和平台规则决定技术这边最多做到不因为程序问题而失败。所以合理的工程目标是把单条生成成本压到可核算范围、把失败率压到可忽略、把重复劳动压到零而不是许诺一个具体收益数字。这也是我在实际项目里坚持的一条原则先用流水线跑 20 条把成本、耗时、抽检合格率三个数据打出来再决定要不要放大到 200 条。没有这三个数据就扩量本质上是在赌。本文后面的代码会内置成本和耗时统计就是为了让你每次运行结束都能看到这三个数。2. 环境准备与项目结构2.1 运行环境与依赖说明本文示例使用的运行环境如下具体版本可以根据你自己的项目情况调整重点是把依赖锁定在同一个虚拟环境里避免版本漂移导致 SDK 参数不兼容操作系统LinuxUbuntu 22.04 及以上或 macOSWindows 建议用 WSL 或 PowerShell 运行同一套代码路径分隔符差异用pathlib屏蔽。Python3.9 及以上本文代码用到了dataclasses和类型注解3.8 也能跑但建议升级。关键依赖OpenAI 兼容协议官方 Python SDK 1.x 版本openai1.0.0。注意 0.x 和 1.x 的调用方式完全不同openai.ChatCompletion.create在 1.x 已经不存在网上很多旧教程就是因为这一点跑不起来。无需额外安装requests、tenacity重试逻辑我们自己写减少依赖层级。依赖文件写成下面这样放在项目根目录# 文件路径requirements.txt openai1.0.0安装命令python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt这里强调每个项目一个虚拟环境不是形式主义。AI 相关 SDK 更新频繁同一台机器上两个项目共用全局环境很容易出现 A 项目能跑、B 项目报TypeError: create() got an unexpected keyword argument的情况排查成本远高于建环境的三十秒。2.2 项目目录结构目录按职责分层避免所有代码堆在一个main.py里。下面这套结构在单人维护、后期加人时都不需要重构# 目录结构 ai-pipeline/ ├── .env # 本地密钥加入 .gitignore ├── .env.example # 配置模板可以提交 ├── requirements.txt ├── config.py # 配置加载 ├── llm_client.py # 模型调用封装 ├── cost.py # 预算熔断 ├── pipeline.py # 调度与落盘 ├── quality.py # 质量抽检 ├── main.py # 入口 ├── tasks.json # 任务输入 ├── logs/ │ └── run.log └── output/ └── results.jsonl # 结果追加写入.env一定要加到.gitignore这是密钥管理的最低要求。很多密钥泄露事故不是被攻击而是提交代码时顺手把.env推到了公开仓库几分钟内就会被扫描到并产生异常调用。把.env.example提交上去、把真实密钥留在本地或部署机的环境变量里是成本最低的防护。2.3 API 凭据与接口地址配置配置项统一放.env代码只认环境变量。下面这份模板可以作为起点单价部分留空或填 0等你确认自己使用的服务实际计费方式后再填# 文件路径.env.example # 密钥只放在本地或部署机环境变量禁止提交到仓库 LLM_API_KEYsk-your-key-here # 接口地址填你所使用服务的 OpenAI 兼容端点 LLM_BASE_URLhttps://api.openai.com/v1 # 模型名按实际可用模型填写 LLM_MODELgpt-4o-mini # 调用参数 LLM_TIMEOUT60 LLM_MAX_RETRIES3 LLM_CONCURRENCY3 LLM_TEMPERATURE0.7 # 成本控制单价按你实际使用的服务填写本文不写死具体价格 MAX_BUDGET5.0 PRICE_IN_PER_1K0 PRICE_OUT_PER_1K0 # 质量下限 MIN_OUTPUT_LENGTH120需要强调的是LLM_BASE_URL这一项只要目标服务兼容 OpenAI 的chat/completions协议改这一行就能切换业务代码完全不用动。这正是 1.2 节说的调用层封装的价值——把变化点收敛到一个配置文件里后续换服务商只改一行。3. 核心模块拆解配置、调用、重试、限流3.1 配置层把密钥和业务参数彻底分离配置层的目标只有一个代码里不出现任何可能随环境变化的值。下面这份config.py做了三件事轻量读取.env不额外引入python-dotenv、对环境变量做类型转换并给出明确报错、把所有参数收敛到一个不可变的AppConfig对象里。为什么用dataclass而不是字典因为字典拼错 key 只会在运行时静默返回None而dataclass拼错属性名会直接AttributeError错误暴露得更早。# 文件路径config.py import os from dataclasses import dataclass from pathlib import Path def _load_dotenv(path: Path Path(.env)) - None: 轻量读取 .env已存在的环境变量优先不覆盖。 if not path.exists(): return for line in path.read_text(encodingutf-8).splitlines(): line line.strip() if not line or line.startswith(#) or not in line: continue key, _, value line.partition() os.environ.setdefault(key.strip(), value.strip()) def _env_int(name: str, default: int) - int: raw os.getenv(name) if raw is None or raw.strip() : return default try: return int(raw) except ValueError as exc: raise ValueError(f环境变量 {name} 必须是整数当前值{raw!r}) from exc def _env_float(name: str, default: float) - float: raw os.getenv(name) if raw is None or raw.strip() : return default try: return float(raw) except ValueError as exc: raise ValueError(f环境变量 {name} 必须是数字当前值{raw!r}) from exc dataclass(frozenTrue) class AppConfig: api_key: str base_url: str model: str timeout: int max_retries: int concurrency: int temperature: float max_budget: float price_in_per_1k: float price_out_per_1k: float min_output_length: int def load_config() - AppConfig: _load_dotenv() api_key (os.getenv(LLM_API_KEY) or ).strip() if not api_key: raise ValueError(缺少 LLM_API_KEY请在 .env 或系统环境变量中配置) return AppConfig( api_keyapi_key, base_url(os.getenv(LLM_BASE_URL) or https://api.openai.com/v1).strip(), model(os.getenv(LLM_MODEL) or gpt-4o-mini).strip(), timeout_env_int(LLM_TIMEOUT, 60), max_retries_env_int(LLM_MAX_RETRIES, 3), concurrency_env_int(LLM_CONCURRENCY, 3), temperature_env_float(LLM_TEMPERATURE, 0.7), max_budget_env_float(MAX_BUDGET, 5.0), price_in_per_1k_env_float(PRICE_IN_PER_1K, 0.0), price_out_per_1k_env_float(PRICE_OUT_PER_1K, 0.0), min_output_length_env_int(MIN_OUTPUT_LENGTH, 120), )这里的几个细节值得说一下。第一_load_dotenv用setdefault而不是直接赋值是为了让 CI/CD 里注入的环境变量优先于本地.env部署时不会因为文件残留导致密钥被覆盖成测试值。第二frozenTrue让配置对象不可修改杜绝了某个函数中途把 concurrency 改成 50这种极难排查的隐式副作用。第三所有类型转换失败都抛出带原始值的异常你在日志里一眼就能看出是哪个变量填错了而不是面对一句干巴巴的ValueError。3.2 调用层统一封装模型 SDK调用层要解决的是屏蔽差异 统一返回值。不管底层是哪个服务上层拿到的都应该是同一种结构内容、输入 token、输出 token、耗时。这样后面的统计、校验、成本计算都不用关心调用细节。先来看一个最直接的封装版本# 文件路径llm_client.py import logging import random import time from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI, RateLimitError from config import AppConfig logger logging.getLogger(__name__) # 可重试异常网络问题、超时、限流、5xx RETRYABLE (APITimeoutError, APIConnectionError, RateLimitError) class LLMClient: 对模型调用的最小封装向上只暴露 chat 方法。 def __init__(self, config: AppConfig) - None: self.config config # max_retries0重试策略由本类控制避免 SDK 内部重试叠加 self._client OpenAI( api_keyconfig.api_key, base_urlconfig.base_url, timeoutconfig.timeout, max_retries0, ) def chat(self, system_prompt: str, user_prompt: str, temperature: float | None None) - dict: temperature self.config.temperature if temperature is None else temperature last_error: Exception | None None for attempt in range(1, self.config.max_retries 1): started time.time() try: resp self._client.chat.completions.create( modelself.config.model, temperaturetemperature, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], ) elapsed round(time.time() - started, 2) content (resp.choices[0].message.content or ).strip() usage getattr(resp, usage, None) return { content: content, prompt_tokens: getattr(usage, prompt_tokens, 0) or 0, completion_tokens: getattr(usage, completion_tokens, 0) or 0, elapsed: elapsed, } except RETRYABLE as exc: last_error exc # 指数退避 随机抖动避免多线程同时重试形成尖峰 wait min(2 ** attempt random.random(), 20.0) logger.warning( 第 %s/%s 次调用失败%s%.1f 秒后重试, attempt, self.config.max_retries, type(exc).__name__, wait, ) time.sleep(wait) except APIStatusError as exc: # 4xx 属于参数或权限问题重试无意义直接抛 logger.error(接口返回不可重试状态码%s响应%s, exc.status_code, exc.message) raise raise RuntimeError(f模型调用连续失败 {self.config.max_retries} 次) from last_error这段代码里有三个容易踩坑但很少被讲清楚的点。第一max_retries0是刻意设置的SDK 自己默认会重试两次如果你再在外面套一层重试实际请求次数会变成乘法关系一次任务可能打出九次请求既浪费额度又拖慢整体速度。第二区分可重试和不可重试异常超时、连接失败、限流429、服务端 5xx 值得重试而 400 参数错误、401 密钥错误、404 模型不存在重试一百次也不会成功反而让日志被垃圾信息淹没。第三退避时间加了随机抖动因为并发场景下所有线程同时失败后再同时重试会形成新的流量尖峰抖动可以把这些请求摊开。返回结构里带elapsed是刻意的它让你在复盘时能回答是模型慢还是我的代码慢这个问题。如果elapsed稳定在 2 秒但整轮耗时 10 分钟说明瓶颈在并发度而不是模型如果elapsed波动在 2 到 30 秒之间就要考虑是不是长文本输出导致的不稳定延。3.3 重试策略的边界什么时候该放弃重试不是越多越好。上面的实现里总耗时上限是max_retries × (timeout 退避时间)按 3 次、60 秒超时、退避 2/4/8 秒计算单条最坏情况要占用接近 3 分钟。如果并发度是 3一轮 100 条任务的最坏耗时会被拉到不可接受的程度。实践中我建议按任务的重要程度分档核心任务当次要交付的保留 3 次重试允许慢批量试探任务先跑 20 条看效果只给 1 到 2 次快速失败比慢慢成功更有价值。这条规则可以用一个简单的参数控制不需要改代码结构# 核心片段按任务重要程度覆盖重试次数 def chat_with_level(client: LLMClient, system_prompt: str, user_prompt: str, level: str normal) - dict: original client.config.max_retries try: # frozen dataclass 不能直接改这里用临时对象演示思路 if level probe: client.config type(original) # 占位实际见下方说明 return client.chat(system_prompt, user_prompt) finally: pass上面这段是示意不要直接抄——frozenTrue的AppConfig本来就禁止修改这正是它的价值。正确做法是把重试次数作为chat的参数传入而不是去改全局配置# 文件路径llm_client.py替换 chat 方法签名中相关部分 def chat(self, system_prompt: str, user_prompt: str, temperature: float | None None, max_retries: int | None None) - dict: retries self.config.max_retries if max_retries is None else max_retries # 后续循环使用 retries 而不是 self.config.max_retries这样调用方可以按场景传max_retries1既不污染全局配置也不需要维护多份客户端实例。这个改动很小但它把重试次数从系统级参数变成了任务级参数是后面做分级任务调度的基础。3.4 并发与限流别把额度打爆并发度的选择取决于两件事服务端的速率限制、你自己的预算。很多人一上来把并发开到 20结果前 30 秒确实爽之后全是 429整体耗时反而比并发 3 更长因为重试把队列堵住了。一个经过验证的经验值先把LLM_CONCURRENCY设为 3跑 20 条任务观察日志里 429 出现的比例。如果一次都没出现可以上调到 5如果超过 10% 的任务触发了 429说明当前并发已经超过服务端配额应该下调。这个调参过程最多花两轮比凭感觉设一个高并发然后反复改代码要省时间得多。另外注意并发和写入的关系。多线程同时往同一个文件追加写如果不做控制会出现内容交错甚至损坏。本文的调度层采用多线程只做调用、主线程统一写入的模式写操作天然串行不需要加锁这是比threading.Lock更简单也更安全的做法第 4 节会给出完整实现。4. 完整实战批量任务流水线4.1 输入数据结构与任务幂等设计任务输入用 JSON 数组每条任务是一个对象。之所以不用 CSV是因为任务里往往带数组字段比如关键词列表CSV 里表达起来很别扭。[ { topic: Python 虚拟环境的作用, keywords: [venv, 依赖隔离, 版本管理], tone: 教学, min_length: 400 }, { topic: JSONL 与 JSON 的区别, keywords: [流式读取, 追加写入, 断点续跑], tone: 教学, min_length: 400 } ]关键设计是任务幂等每条任务需要一个稳定 ID由任务内容计算得出而不是随机生成或者用数组下标。原因很实际——你在中途往tasks.json里插了一条新任务所有下标都错位了用下标做 ID 会导致断点续跑时跳过错误的任务。用内容哈希就没这个问题# 文件路径pipeline.py片段 import hashlib import json def task_id(task: dict) - str: 由任务内容生成稳定 ID内容相同则 ID 相同。 raw json.dumps(task, ensure_asciiFalse, sort_keysTrue) return hashlib.md5(raw.encode(utf-8)).hexdigest()[:12]sort_keysTrue不能省否则字典键顺序不同会算出不同哈希幂等就失效了。截取 12 位是为了日志好读碰撞概率在单机任务量级下可以忽略。4.2 调度层并发调用、串行落盘调度层的核心约束只有一条调用可以并发写入必须串行。下面这份pipeline.py同时实现了断点续跑、失败记录和统计累计。# 文件路径pipeline.py import hashlib import json import logging from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from config import AppConfig from llm_client import LLMClient logger logging.getLogger(__name__) SYSTEM_PROMPT ( 你是一名技术内容作者。请围绕给定主题输出结构清晰的中文说明。 要求不使用夸张宣传语不虚构数据和经历涉及操作步骤时给出可执行的说明。 ) def task_id(task: dict) - str: raw json.dumps(task, ensure_asciiFalse, sort_keysTrue) return hashlib.md5(raw.encode(utf-8)).hexdigest()[:12] def build_prompt(task: dict) - str: keywords 、.join(task.get(keywords, [])) return ( f主题{task[topic]}\n f需要覆盖的关键词{keywords or 无}\n f语气风格{task.get(tone, 教学)}\n f目标长度不少于 {task.get(min_length, 300)} 字\n 输出要求分成 2 到 4 个小段落每段说明一个要点。 ) def run_one(task: dict, client: LLMClient, config: AppConfig) - dict: result client.chat(SYSTEM_PROMPT, build_prompt(task)) content result[content] if len(content) config.min_output_length: raise ValueError(f输出过短{len(content)} 字疑似模型截断或拒答) return { task_id: task_id(task), topic: task[topic], output: content, prompt_tokens: result[prompt_tokens], completion_tokens: result[completion_tokens], elapsed: result[elapsed], } def load_done_ids(results_path: Path) - set[str]: 读取已有结果得到已完成任务 ID 集合。 done: set[str] set() if not results_path.exists(): return done with results_path.open(r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue try: done.add(json.loads(line)[task_id]) except (json.JSONDecodeError, KeyError): logger.warning(跳过一行无法解析的结果记录) return done def run_pipeline(tasks: list[dict], client: LLMClient, config: AppConfig, results_path: Path, guardNone) - dict: results_path.parent.mkdir(parentsTrue, exist_okTrue) done load_done_ids(results_path) pending [t for t in tasks if task_id(t) not in done] logger.info(任务总数 %s已完成 %s待处理 %s, len(tasks), len(done), len(pending)) stats {ok: 0, fail: 0, prompt_tokens: 0, completion_tokens: 0} with results_path.open(a, encodingutf-8) as f, \ ThreadPoolExecutor(max_workersmax(config.concurrency, 1)) as pool: future_map {pool.submit(run_one, task, client, config): task for task in pending} for future in as_completed(future_map): task future_map[future] record None try: record future.result() stats[ok] 1 stats[prompt_tokens] record[prompt_tokens] stats[completion_tokens] record[completion_tokens] except Exception as exc: stats[fail] 1 record {task_id: task_id(task), topic: task.get(topic), error: str(exc)} logger.error(任务失败 %s%s, record[task_id], exc) # 只有主线程写文件天然串行不需要额外加锁 f.write(json.dumps(record, ensure_asciiFalse) \n) f.flush() if guard is not None and prompt_tokens in record: guard.add(record[prompt_tokens], record[completion_tokens]) return stats有几个实现细节值得单独说明。第一f.flush()每写一条就刷一次盘这是为了保证程序被强制终止时已经完成的任务不会丢代价是轻微的 IO 开销在每秒几条的量级完全可以接受。第二失败的任务也写进结果文件但记录里是error字段而不是output——这一点很重要否则下次续跑时它会因为task_id已存在而被跳过你永远不知道它失败了。第三如果你确实想重试失败任务可以额外写一个脚本把含error的行挑出来重新入队这比在主流程里做复杂的状态机要清晰得多。4.3 成本熔断让程序自己踩刹车成本控制不能靠人盯着。下面的BudgetGuard在每条结果写完后累计成本超过预算立刻抛异常终止整轮剩下的任务不会被提交到线程池之外的新批次里线程池内部已提交的会继续跑完这是可接受的损失。# 文件路径cost.py class BudgetExceeded(RuntimeError): 预算超限用于中断整轮任务。 class BudgetGuard: 按 token 累计成本超过上限抛异常。单价单位元 / 千 token。 def __init__(self, max_budget: float, price_in_per_1k: float, price_out_per_1k: float) - None: self.max_budget max_budget self.price_in price_in_per_1k / 1000.0 self.price_out price_out_per_1k / 1000.0 self.used 0.0 def add(self, prompt_tokens: int, completion_tokens: int) - float: self.used prompt_tokens * self.price_in completion_tokens * self.price_out if self.used self.max_budget: raise BudgetExceeded( f累计成本 {self.used:.4f} 已超过预算 {self.max_budget:.4f}本轮中断 ) return self.used单价从配置里读、默认填 0是刻意为之不同服务、不同模型的计费方式差异很大把具体价格写死在代码里既容易过时也容易让读者误以为那是当前真实价格。你只需要在.env里把PRICE_IN_PER_1K和PRICE_OUT_PER_1K按自己实际使用的服务填写成本统计就能正常工作。填 0 时这个模块退化为纯 token 统计不会误触发熔断也不会影响主流程。max_budget建议按一轮最大可承受损失来设而不是按月预算。比如一轮 100 条任务预估成本 2 元那把它设成 5 元就很合适既能容忍少量重试又能在出现异常长文本时及时刹车。4.4 入口文件与运行验证把上面几块拼起来入口文件保持极简只负责组装和输出统计# 文件路径main.py import json import logging from pathlib import Path from config import load_config from cost import BudgetExceeded, BudgetGuard from llm_client import LLMClient from pipeline import run_pipeline def main() - None: logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(name)s - %(message)s, ) config load_config() tasks json.loads(Path(tasks.json).read_text(encodingutf-8)) client LLMClient(config) guard BudgetGuard( max_budgetconfig.max_budget, price_in_per_1kconfig.price_in_per_1k, price_out_per_1kconfig.price_out_per_1k, ) try: stats run_pipeline(tasks, client, config, Path(output/results.jsonl), guardguard) except BudgetExceeded as exc: logging.error(已触发预算熔断%s, exc) return logging.info( 本轮结束成功 %s失败 %sprompt_tokens%scompletion_tokens%s估算成本 %.4f, stats[ok], stats[fail], stats[prompt_tokens], stats[completion_tokens], guard.used, ) if __name__ __main__: main()运行命令python main.py预期输出大致如下时间戳和数值随实际调用变化2024-06-01 10:12:03,115 INFO pipeline - 任务总数 2已完成 0待处理 2 2024-06-01 10:12:09,882 INFO __main__ - 本轮结束成功 2失败 0prompt_tokens412completion_tokens903估算成本 0.0000再次执行同一条命令你会看到已完成 2待处理 0说明断点续跑生效了。这一步建议一定要自己验证一遍因为它是后续所有增量任务的基础——很多人写完流水线第一次中断就发现结果文件被覆盖一半工作白做。4.5 结果查看与增量补跑结果文件是 JSONL一行一个对象可以直接用命令行抽样查看# 查看第一条结果的正文 head -n 1 output/results.jsonl | python -c import sys,json;print(json.loads(sys.stdin.read())[output]) # 统计成功与失败条数 python - PY import json ok fail 0 for line in open(output/results.jsonl, encodingutf-8): line line.strip() if not line: continue rec json.loads(line) if error in rec: fail 1 else: ok 1 print(成功, ok, 失败, fail) PY当你想补跑失败任务时最省事的做法是把失败记录导出成一个新的tasks.json只保留topic相关字段去掉task_id然后正常执行python main.py。因为失败记录没有被算进已完成集合——注意前面的load_done_ids会把所有行都当作已完成包括失败行。如果你希望失败任务能自动重跑把load_done_ids改成只收集不含error的行即可# 文件路径pipeline.pyload_done_ids 中一行改动 rec json.loads(line) if error not in rec: done.add(rec[task_id])这是一个需要你根据业务决定的选择自动重跑方便但会陷入某条任务永远失败、每轮都重试的死循环。我的建议是自动重跑 失败次数上限用一个小字典记录每个task_id的失败次数超过 2 次就不再纳入待处理列表。5. 质量校验与人工复核的边界5.1 三类可以自动化的校验流水线跑通之后下一个问题是生成的东西能不能用。有三类校验完全可以用代码做成本几乎为零长度与结构输出过短通常意味着模型拒答或截断输出过长可能是没有遵守指令。用min_output_length兜住下限上限按业务需要设。关键词覆盖如果任务里指定了必须出现的关键词直接做子串匹配算出覆盖率。覆盖率低于 60% 的记录单独标记优先人工看。重复度批量生成最大的隐患是内容同质化。用字符级 n-gram 的 Jaccard 相似度做两两比较相似度高于阈值比如 0.6的标记为疑似重复。这个方法不需要任何模型几十行代码就能跑。# 文件路径quality.py import json import re from pathlib import Path def keyword_coverage(text: str, keywords: list[str]) - float: if not keywords: return 1.0 hit sum(1 for k in keywords if k and k in text) return hit / len(keywords) def ngram_set(text: str, n: int 4) - set[str]: compact re.sub(r\s, , text) if len(compact) n: return {compact} if compact else set() return {compact[i:i n] for i in range(len(compact) - n 1)} def jaccard(a: str, b: str, n: int 4) - float: sa, sb ngram_set(a, n), ngram_set(b, n) if not sa or not sb: return 0.0 return len(sa sb) / len(sa | sb) def review(results_path: Path, min_coverage: float 0.6, dup_threshold: float 0.6) - list[dict]: records [] for line in results_path.read_text(encodingutf-8).splitlines(): line line.strip() if not line: continue rec json.loads(line) if error in rec: continue records.append(rec) issues [] accepted: list[dict] [] for rec in records: reasons [] keywords rec.get(keywords) or [] if keyword_coverage(rec[output], keywords) min_coverage: reasons.append(关键词覆盖不足) for prev in accepted: if jaccard(rec[output], prev[output]) dup_threshold: reasons.append(f与 {prev[task_id]} 高度相似) break if reasons: issues.append({task_id: rec[task_id], reasons: reasons}) else: accepted.append(rec) return issues if __name__ __main__: for item in review(Path(output/results.jsonl)): print(item[task_id], |, .join(item[reasons]))注意review里对重复度的处理是只和已通过校验的记录比较这样一条重复内容只会被标记一次不会出现连锁误报。如果任务表里带keywords字段记得在run_one的返回值里把它带出来否则review拿不到关键词。5.2 必须留给人工判断的部分有三件事不建议交给代码自动决定事实准确性、合规性、是否发布。事实准确性方面模型可能把不存在的方法名、参数名写成确定事实这类错误机器很难识别只能靠人工抽查合规性方面是否涉及他人版权素材、是否需要在发布时标注 AI 生成属于业务和平台规则范畴代码只能做关键词黑名单初筛最终判断必须有人是否发布更是如此自动化发布的风险远高于自动化生成一旦批量发出问题内容召回成本极高。我通常的做法是机器筛掉明显不合格的过短、重复、关键词缺失剩下的按 10% 比例随机抽样人工看抽样合格率低于 80% 就停下来改提示词或者改任务拆分方式而不是继续扩量。这个流程听起来保守但它能保证你在放大规模之前先知道真实的质量水位。6. 定时调度与部署6.1 用 cron 做最简单的定时流水线验证通过后最简单的定时方式就是系统自带的 cron不需要引入任何调度框架# 编辑当前用户的定时任务 crontab -e # 每天 08:30 执行一轮日志追加写入 30 8 * * * cd /opt/ai-pipeline /opt/ai-pipeline/.venv/bin/python main.py logs/run.log 21这里有几个细节容易出问题。第一cd到项目目录是必须的因为代码里读的是相对路径tasks.json和output/cron 的默认工作目录是用户家目录不cd会直接报文件不存在。第二用虚拟环境里的绝对路径/opt/ai-pipeline/.venv/bin/python而不是裸python因为 cron 的PATH和你在终端里的不一样。第三21把标准错误重定向到日志否则报错信息会被 cron 通过邮件发出去多数机器上邮件服务没配等于直接丢失。Windows 上可以用任务计划程序配置同样的命令触发器设为每天固定时间操作里填虚拟环境python.exe的完整路径起始位置填项目目录。逻辑和 cron 一致只是配置界面不同。6.2 日志规范与轮转日志至少要包含四类信息任务开始时的总量统计、单条失败的原因和 ID、预算熔断事件、结束时的汇总。上面代码里的logger.info/logger.error已经覆盖了这些但还缺轮转——如果每天跑几轮、每轮几十条日志文件几个月就会涨到几百 MB。最省事的方案是用系统的logrotate配置一个/etc/logrotate.d/ai-pipeline# 文件路径/etc/logrotate.d/ai-pipeline /opt/ai-pipeline/logs/run.log { daily rotate 14 compress missingok notifempty copytruncate }copytruncate这一项在进程持续往文件追加写的场景下很关键它保证轮转时不需要重启进程。rotate 14表示保留两周出问题时回溯两周的日志通常够了。6.3 部署时的三条硬性要求第一密钥只走环境变量。部署机上不要留.env文件改用 systemd 的Environment或者容器编排的密钥管理避免配置文件被误打包、误备份。第二先在小批量上验证。部署到新机器后先手动跑一次只含 2 条任务的tasks.json确认网络、密钥、目录权限、写入路径全部正常再交给 cron。直接上全量任务一旦路径写错你会得到一堆失败记录还要手工清理。第三给进程加超时保护。cron 启动的任务如果因为网络问题卡住可能一直挂在那里下一轮又启动一个新进程最后同一份结果文件被多个进程同时写。用timeout命令包一层最简单# 最多运行 30 分钟超时后强制结束 30 8 * * * cd /opt/ai-pipeline timeout 1800 /opt/ai-pipeline/.venv/bin/python main.py logs/run.log 217. 常见问题与排查思路下面这张表覆盖了我在实际使用中最常遇到的几类问题按现象 → 原因 → 思路组织方便你直接对照日志定位。问题现象常见原因解决思路启动即报缺少LLM_API_KEY.env未创建、变量名拼错、cron 环境下未加载先echo $LLM_API_KEY确认再检查.env是否在项目根目录cron 场景改用 systemd 注入或source环境文件TypeError: create() got an unexpected keyword argument安装的是 openai 0.x 版本pip show openai查看版本升级到 1.x 后按新接口改写调用大量 429 限流错误并发度过高超过服务端配额把LLM_CONCURRENCY降到 3 再观察确认无 429 后再逐步上调401 未授权密钥过期、密钥与服务地址不匹配检查密钥是否属于当前接口地址对应的服务重新生成密钥后重试输出明显被截断输出长度超过服务端限制或提示词要求过长拆分任务、降低单条目标长度并在run_one里用长度下限兜住结果文件里同一任务出现两次两个进程同时运行或task_id计算不稳定用timeout 锁文件防止并发检查task_id是否用了sort_keys断点续跑后任务全部重跑结果路径被改、文件被清空、load_done_ids读不到打印results_path.resolve()确认路径检查文件是否被轮转策略清掉成本统计一直是 0PRICE_IN_PER_1K/PRICE_OUT_PER_1K未填按实际计费单价填入.env或仅依赖 token 数做相对比较定时任务不执行cron 的PATH/工作目录与终端不同全部改绝对路径命令前加cd把输出重定向到日志再观察排查时有一个通用顺序值得记住先看日志最后 20 行再看结果文件最后 1 条记录最后才看代码。绝大多数问题在这两步就能定位——日志会告诉你异常类型结果文件会告诉你卡在哪条任务上比通读代码快得多。8. 最佳实践与合规边界8.1 工程层面的六条习惯幂等优先于状态机。用内容哈希做任务 ID让跑过一次这件事可以被文件本身证明比维护一个state.json记录进度要可靠得多因为状态文件本身也会损坏。失败要留痕不要静默跳过。失败任务写进结果文件并带error字段是本流水线里最重要的一个设计。静默跳过的后果是你以为跑完了 100 条实际上只有 60 条成功而你没有证据。配置和代码分离到极致。模型名、并发数、预算、单价全部走环境变量。这不是为了好看而是为了让你在不改代码、不重新部署的情况下调整运行参数尤其在成本失控时能立刻降并发、降预算。先小批量再放大。任何提示词改动、模型切换、并发调整都先在 5 到 20 条的小样本上验证看成本、耗时、抽检合格率三项数据再决定是否跑全量。给每个关键指标留观测点。至少要有本轮任务数、成功数、失败数、token 总量、估算成本、平均单条耗时。这六个数字能让今天为什么慢这类问题在两分钟内回答。不要在代码里写死具体服务商的价格和限制。这些信息变化快写死之后文章和代码都会迅速过时读者照抄还会误判成本。用配置项表达是唯一稳妥的做法。8.2 合规与风险边界这一节必须单独讲因为自动化批量生成内容涉及的边界比纯技术问题更多。以下几条是我认为必须守住的不承诺收益不把工具包装成赚钱机器