
我把昨天那个 AI Agent今天升级成了每天自己跑的版本接上一篇我用一周时间从零做了一个 AI 信息采集 Agent如果说 v1.0 是能跑给你看v1.1 就是它每天自己跑给我看。这中间隔的不只是几行代码是工程思维的彻底升级。楔子从demo到系统的距离昨天 v1.0 发布的时候我拍着胸脯说项目跑通了。但今天早上一打开 logs发现 v1.0 的跑通其实是这样的✅ 1 条数据入库Rickroll MV ⚠️ Monitor 扫 2 个 UP 主1 个 412 失败 ⚠️ 那 1 条入库的数据up_name 字段是 null ⚠️ 数据库里有 1 条 FAILED 任务永远停在那 ⚠️ 想跑必须我手动 python main.py这是demo。这不是系统。demo 和系统的区别我后来想了想是这 5 件事demo系统触发方式我手动跑自己定时跑遇到错误挂掉自动恢复数据质量看运气持续改进可观测性print 满屏结构化日志 摘要变更安全改完心慌改完有信心今天一整天我做的事情就是把项目从左边推到右边。目录一、开工前的诊断二、阶段 1让 LLM 不再因为 JSON 截断挂掉三、阶段 2B 站反爬的真正根因四、阶段 2.5意外修复的 async bug五、阶段 3失败任务的自动复活六、阶段 4不用我管它每天自己跑七、阶段 5发布 v1.1八、复盘今天到底学到了什么一、开工前的诊断写代码之前我做的第一件事是跑一次 v1.0 的 main.py把所有问题摆在桌面上。这是工程师跟急于动手的人最大的区别先看清楚再下刀。跑出来的真实日志[Stage 1] Monitoring targets for new content... [Monitor] Scanning UID 285286947 ... Found 0 latest videos [Monitor] Scanning UID 1333131174 ... Error: 412 Precondition Failed [Stage 2] No pending tasks. [Stage 3] No collected tasks waiting for LLM. Pipeline completed in 0.5s New URLs: 0 Collected: 0 Processed: 0数据库现状COMPLETED: 1 ← 昨天的 Rickroll MV FAILED: 1 ← 不知道为什么挂的诊断完毕问题清单Monitor 完全失灵412 反爬Collector 选择器疑似过期v1.0 那条数据 up_name 是 nullLLM JSON 截断max_tokens2000 隐患还在FAILED 任务没人管永远停在那重要的一步我把这份诊断报告先发给我自己作为今天工作的 baseline。改完后再跑一次同样的命令对比前后变化——这是衡量今天有没有真正改善的唯一客观标准。 教训不要凭感觉判断项目变好了。用数字说话。二、阶段 1让 LLM 不再因为 JSON 截断挂掉第一个修的是 LLM。原因很简单——这是最容易修的最先做能立刻找回信心。v1.0 的 processor.py 长这样responseclient.chat.completions.create(modelself.model,messages[...],temperature0.2,max_tokens2000# ⚠️ 长内容会被截断)raw_outputresponse.choices[0].message.content.strip()try:parsedjson.loads(raw_output)# ⚠️ 截断了就 raisereturnjson.dumps(parsed)exceptjson.JSONDecodeErrorase:print(f[WARN] LLM returned invalid JSON:{e})# ⚠️ print 不是 loggerreturnNone# ⚠️ 失败的原始输出没保存无法复盘我数了一下这段代码里有 4 个不同层次的问题层次问题后果配置max_tokens2000长内容必崩解析单层 json.loads任何噪音直接 raise日志print没法收集到 logger 系统可观测失败丢掉原文永远无法分析为什么 LLM 抽风我重写了一版把这 4 件事一起解决classLLMProcessor:MAX_TOKENS4000# 翻倍INPUT_CHAR_LIMIT8000# 输入也加大LLM_TIMEOUT_SEC90# ⚠️ 后面踩坑后加的staticmethoddef_safe_json_parse(raw_output):两段式解析应对 LLM 各种抽风textraw_output.strip()# 去掉 json 包裹iftext.startswith():linestext.splitlines()[1:-1]text\n.join(lines).strip()# 第一次尝试直接解析try:returnjson.loads(text),Noneexceptjson.JSONDecodeErrorase:first_errstr(e)# 第二次尝试截取最外层大括号start,endtext.find({),text.rfind(})ifstart!-1andendstart:try:returnjson.loads(text[start:end1]),Noneexceptjson.JSONDecodeErrorase:returnNone,ffirst:{first_err}| bracket:{e}returnNone,ffirst:{first_err}| no balanced bracesdef_dump_failure(self,raw_output,error_message):失败原文落盘方便复盘tsdatetime.now().strftime(%Y%m%d_%H%M%S_%f)pathself.FAILURE_LOG_DIR/fllm_fail_{ts}.txtpath.write_text(f{error_message}\n---\n{raw_output})然后用 6 个边界测试验证_safe_json_parse# 1. 干净 JSON → ✅# 2. json 代码块包裹 → ✅# 3. 前后带解释文字好的结果是 → ✅# 4. 截断的 JSON → ✅ 优雅返回 None error# 5. 空字符串 → ✅# 6. 代码块 尾部换行 → ✅ 教训重写不是只改坏的那一行。每次重写都问自己——“这段代码隐含了什么假设这些假设以后会不会变”比如这个 LLM 调用原代码假设了 a) 输出永远是合法 JSON、b) 输出永远不超过 max_tokens、c) 失败信息不需要保存。把这些假设全部颠倒过来重新设计就是 v1.1。三、阶段 2B 站反爬的真正根因这是今天最难的部分。也是收获最大的部分。v1.0 时我以为 412 是简单的反爬加点 headers 就好了。但当我用 Playwright 打开浏览器带着真实的 cookie 去访问那个 API结果让我吓了一跳HTTP 200 { code: -799, message: 请先登录..., data: null }不是 HTTP 错误是业务错误码 -799。查了半天才发现B 站 2024 年起对/x/space/arc/search接口加了WBI 签名机制——必须在请求里带上w_rid和wts两个参数参数值由复杂的 MD5 二次混淆算法生成。光有 cookie 不够必须有签名。这是一个真正的硬技术墙传统反爬 HTTP 412 / 403 / 429 → 加 headers / 加 cookie 就能过 WBI 签名 HTTP 200 code-799 → 必须实现签名算法cookie 完全无效我在这里做了一个重要的工程决策不去硬磕签名算法。因为 a) 算法会变b) 维护成本高c)我有更稳的路径——Playwright。于是 monitor.py 的设计变成了双路径 智能 fallbackdeffetch_bilibili_urls(self,uid):# 第一路径requests 试一下快但脆弱urlsself._fetch_via_requests(uid)ifurlsisnotNone:returnurls# 第二路径Playwright fallback慢但稳returnself._fetch_via_playwright(uid)关键是怎么判断要切换路径。我没用粗暴的 try/except而是精确识别 B 站的业务错误码NEED_FALLBACK_CODES{-799,-412,-403,-509}ifdata.get(code)inNEED_FALLBACK_CODES:returnNone# 触发 fallbackifdata.get(code)-111:return[]# UP 主真的不存在不要 fallback这种精确识别 智能降级的设计是这阶段我学到最重要的事 教训反爬不是一道墙是一棵决策树。HTTP 错误码、业务错误码、空响应、超时——每一种失败都对应不同的根因和不同的修复策略。粗暴的 try/except 会把这些信号全部抹平。写一个错误码识别表比写 100 行重试逻辑值钱。实地探测的时候我还顺手发现了选择器全部过期.video-desc ❌ 已下线v1.0 一直在用 .desc-content ❌ 已下线 .video-desc-container ✅ 新主选择器 #v_desc ✅ 新主选择器collector.py 整个重写每个字段都用 3-5 个备选选择器降级。四、阶段 2.5意外修复的 async bug写完阶段 2 跑起来cookie 注入成功monitor 拿到 30 个新视频——我以为搞定了。然后端到端跑main.py崩了RuntimeError: This event loop is already running定位发现v1.0 时 main.py 是用asyncio.run(pipeline.run())启动的但 stage1_monitor 是同步函数——v1.0 时不调用 Playwright fallback 所以无所谓但 v1.1 我加的 Playwright fallback 内部用了 asyncio.run()# v1.1 我写的同步包装错的def_fetch_via_playwright(self,uid):returnasyncio.run(self._fetch_via_playwright_async(uid))# ↑ 已经在 asyncio.run() 里了再调一次 → 撞车这就是著名的“event loop already running”反模式。这个 bug 在 v1.0 不存在是因为 v1.0 的 monitor 永远不会进 Playwright 路径。v1.1 引入 fallback 才让它显形。修复方案是把 monitor 的 API 拆成同步 异步两个版本# 同步版独立脚本调用deffetch_bilibili_urls(self,uid):...returnself._fetch_via_playwright(uid)# 内部 asyncio.run# 异步版main.py 这种 async 上下文调用asyncdeffetch_bilibili_urls_async(self,uid):...returnawaitself._fetch_via_playwright_async(uid)# 直接 awaitmain.py 里改一行# Beforeaddedself.monitor.sync_targets(self.TARGET_UIDS)# Afteraddedawaitself.monitor.sync_targets_async(self.TARGET_UIDS) 教训新功能可能会让旧代码的潜在 bug 显形。这个 async bug 在 v1.0 时一直存在但因为永远不触发那条路径无事发生。v1.1 触发了路径bug 就来敲门了。写代码的时候永远要意识到你没走过的路——可能未来某天有人会走那时候就不一定是你来修。五、阶段 3失败任务的自动复活跑批的时候发现火山方舟 kimi-k2.6 偶发会出现90 秒以上的长尾延迟——单条 LLM 调用卡住整条流水线没法继续。修了一个 timeoutself.client.chat.completions.create(...timeout90,)但更深的问题是失败的任务永远停在 FAILED 状态没人管。v1.0 的失败处理# 失败了 → 标记 FAILED → 然后呢self.db.update_task_status(url,FAILED)没有然后。FAILED 任务永远不会被重新尝试。我设计了一个智能重试机制。关键洞察是失败要分类。LLM 阶段失败和 collector 阶段失败需要不同的恢复策略。defrequeue_failed(self,max_retry3):智能恢复 FAILED 任务# 策略 1已经有 raw_contents → LLM 阶段失败# 不用重新爬直接把状态回到 COLLECTED 让 LLM 重试UPDATE task_queue SET statusCOLLECTEDWHERE statusFAILEDAND retry_count? AND url IN(SELECT url FROM raw_contents)# 策略 2没有 raw_contents → collector 阶段失败# 需要重新爬回到 PENDINGUPDATE task_queue SET statusPENDINGWHERE statusFAILEDAND retry_count?# 策略 3retry_count 3 → 放弃保留 FAILED# 不做任何 UPDATEmain.py启动时自动调用asyncdefrun(self):# v1.1每次启动先回滚 FAILEDself.db.requeue_failed()# 然后正常跑流水线new_urlsawaitself.stage1_monitor()...这个机制的真实威力v1.0 时代留下的那条 FAILED 任务昨天 LLM 截断的那条今天我什么都没做就被自动复活并处理成功了[Stage 3] (1/2) Cleaning: https://www.bilibili.com/video/BV15f4y1A7Hi [Processor] LLM extracted OK. Title: 【中英字幕】《baby》- Justin Bieber 贾斯汀比伯 1080P超高清MV [Stage 3] OK那一刻系统第一次有了自愈能力。 教训让系统会自我修复比让代码不出错更重要。错误是必然的网络抖、LLM 抽风、DOM 改版。承认它必然设计一个能自愈的机制——比花 10 倍时间追求零错误实际多了。六、阶段 4不用我管它每天自己跑到这里项目已经能稳定运行了。但还需要我手动python main.py——这就还是个工具不是系统。我设计了双轨调度轨道 A传统 crontab项目自带作品集价值#!/bin/bash# run.shcd$(dirname$0)source../.venv/bin/activateif[$1--batch];thenpython run_batch.py$2elsepython main.pyfi挂到 crontab30 9 * * * /path/to/ai_collector_project/run.sh任何用户 clone 我的仓库都能马上用。轨道 BHermes Agent 调度私人优化我用的工具是 Hermes Agent自带 cron 调度器。但有个问题——main.py 输出大量调试日志直接发到飞书会把消息流刷爆。写了一个专用的 cron entrypoint只输出 8 行精简摘要# ai_collector_cron.pydefmain():# 跑前后对比 DB 状态dbDBManager()beforedb.get_run_summary()# 静默跑吞掉详细日志withredirect_stdout(silenced):asyncio.run(AIPipeline().run())afterdb.get_run_summary()delta_completedafter[COMPLETED]-before[COMPLETED]# 寂静策略什么都没发生 → 不打扰ifdelta_completed0and...:print([SILENT])return# 输出精简摘要≤ 8 行print( AI Collector 每日报告)print(f✅ 本次处理{delta_completed}条)print(f 数据库COMPLETED{after[COMPLETED]}/ PENDING{after[PENDING]})print( 最近入库)fortitleinget_latest_titles(3):print(f •{title})最后挂到 Hermes croncronjob.create(nameAI Collector daily run,schedule30 9 * * *,no_agentTrue,scriptai_collector_daily.sh,deliverfeishu)第二天早上 9:30飞书自动收到 AI Collector 每日报告 ✅ 本次处理3 条 数据库COMPLETED 18 / PENDING 24 / FAILED 0 最近入库 • 智谱发布 GLM-5.2 模型 • Anthropic 推出 Claude Fable 5 • Google 发布 DiffusionGemma 详情见 logs/pipeline.log项目从此完成自治——我什么都不用做它每天自己跑每天给我送早报。 教训自动化的最后一公里是通知。写完调度代码不算完。摘要怎么呈现、什么时候保持沉默、错误怎么提醒——这些细节决定了用户是享受系统还是被消息淹没。我特别喜欢寂静策略这个设计——没有新数据时绝对不打扰你。七、阶段 5发布 v1.1最后一步把所有改动整理成一个正式的 release。gitpush origin v1.1-stability ghprcreate--basemain--headv1.1-stability\--titlev1.1 Stability Releaseghprmerge1--squash--delete-branch gh release create v1.1--titlev1.1 Stability Release仓库地址https://github.com/nakajimamiyuki/ai_collector_project7 个 commit1021/-174 行10 个文件改动文件改动src/processor.pyLLM 健壮化 timeoutsrc/monitor.pyB 站反爬升级 双路径 async APIsrc/collector.py选择器全面更新 容错降级src/db_manager.py失败重试 schema migrationmain.py异步化 失败回滚 摘要增强run.shcrontab wrapperrun_batch.py批量补跑工具ai_collector_cron.pyHermes cron entrypoint.env.exampleBILI_COOKIE 模板README.mdChangelog Roadmap 重梳八、复盘今天到底学到了什么v1.0 教会我怎么做v1.1 教会我怎么做对v1.0让代码能跑 v1.1让代码该跑成什么样 v1.0用 print 看进度 v1.1用 logger 收集 metrics v1.0失败抛 exception v1.1失败分类 → 自愈策略 v1.0手动跑 v1.1自动跑 智能摘要 v1.0写完就 commit v1.1先诊断 → 再修 → 再验证5 个关键工程教训1. 修代码前先做诊断我不是上来就改代码是先跑了一次 v1.0、看清楚 4 个真实问题、按优先级排序再下刀。这步省了至少 2 小时。2. 错误码是金矿不是噪音B 站的 -799 / -412 / -111 不是反爬是精确的状态信号。把它们识别出来就能写出智能降级而不是暴力重试的代码。3. 失败要分类LLM 阶段失败 ≠ collector 阶段失败 ≠ 网络失败。每种失败有对应的恢复策略。统一 try/except 抹平了所有有用的信息。4. 自愈机制比无 bug 更值钱任何系统都会出错。真正的生产级不是不出错是出错了能自己恢复。5. 自动化的最后一公里是通知调度跑通了不等于完成。怎么把结果优雅地呈现给用户——寂静策略、精简摘要、错误提醒——决定了系统的用户体验。数字总结代码层面 1021 / -174 行 7 commits 10 文件改动 0 lint 错误 数据层面 数据库从 1 → 27 条真实 AI 行业新闻 字段命中率title 100% / tags 100% / summary 100% FAILED 任务从 1 → 0全部自动复活 工程层面 从手动跑→每天 09:30 自动跑 从挂掉等修→失败自动重试 3 次 从看天用→双路径 fallback距离 AI Agent 工程师的进步如果说 v1.0 让我证明我能写出 AI Agent 项目v1.1 让我证明我能把项目工程化。这两件事在面试官眼里完全不是一个量级写出 demo很多人能做把 demo 升级成系统少数人能做能讲清楚为什么这么升级稀缺我现在能完整讲清楚v1.0 之后我做了一份复盘发现 4 个真实问题。我没有一上来就改代码而是先把问题分类——配置层、解析层、可观测层、可恢复层。然后按价值/难度排序从最容易的 LLM 修起逐步推进到 B 站反爬、失败重试、定时调度。中间踩到了 async event loop 的反模式 bug、kimi-k2.6 的长尾延迟问题、main.py 直接调 SQLite 破坏抽象的设计缺陷。每一个都有 git commit message 记录根因和修法。最终发布了 v1.1 release附带完整 changelog 和 roadmap。整个仓库公开在 GitHub。这一段话是这个项目对我求职最大的产出。比代码本身值钱 10 倍。尾声这个项目对我来说是从能写代码到能交付系统的分水岭。如果你跟我一样在转型路上想做但不知道做到什么程度才算能拿出手时间有限我每天只有 2-3 小时那我建议你也试试这种节奏第 1 天做 v1.0让它能跑哪怕踩 8 个坑 第 2 天写复盘列出问题 第 3 天做 v1.1把问题分类逐个修 第 4 天写博客把为什么这么做讲清楚代码会被遗忘但工程思维不会。项目地址https://github.com/nakajimamiyuki/ai_collector_project下一篇预计写怎么把 final_results 表里的数据用起来——daily brief、语义搜索、RAG 应用。一个还在路上的人。