1. 为什么偏偏选 LlamaIndex ReAct:博客监控这个场景的需求拆解
先说个我自己的痛点。手头长期维护着一个阅读清单,里面是十几个技术团队博客、个人独立站点和个人博客,内容是分散的,更新频率也完全不一样。有些站点一个月憋一篇,有些一周更新两三次。之前在用的聚合方式无非是手动刷新和简单的RSS订阅,但问题很明显:RSS 对全文输出的站点还好,对只输出摘要的站点就很鸡肋,更别提那些没有 RSS 的老博客。更麻烦的是,我只关心某几类主题,比如大模型应用、自动化框架、某个工具的版本迭代,RSS 把新文章一股脑全推过来,筛选还是要自己做。
那能不能把"检测新增文章 → 判断是否值得看 → 给摘要 → 分门别类通知我"这条链路,做成一个不需要人工参与的东西?这就是"全自动博客监控 Agent"想解决的问题。
标题里的几个词,其实对应了几个非常具体的选择:用 LlamaIndex 是因为它的文档处理体系太适合"把网页变成可操作的结构化信息"了;用 ReAct 框架(Reason + Act 交替循环)是因为这个场景本质上是"不断推理下一步该做什么"的任务型循环,而不是一次问答就能结束的;手搓是因为现成的监控工具做不到"按我的兴趣判断文章值不值得看"这种主观过滤,必须自己定逻辑。
不过先给结论,这轮实战里我最想分享的不是"代码跑通了",而是三个更值钱的认知:第一,Agent 类工程 90% 的复杂度都在工具函数的设计,而不在模型调用;第二,ReAct 循环跑起来之后,"控制频率与开销"远比"让模型想得更聪明"更重要;第三,监控类 Agent 的验收标准不是"能聊天",而是"连续跑一周不崩、不漏、不闹"。
如果你也想做一个类似的东西——不管是监控博客、监控文档更新,还是盯某个网站的公告页——这篇实战笔记可以直接照着抄。我会把从环境准备、核心代码、实测日志到调试避坑的完整路径都写出来。
2. Agent 的"五脏六腑":监控引擎的架构设计与数据流
在贴代码之前,先把整体设计讲清楚。很多人一上来就调模型写 Prompt,结果连"Agent 该有几颗牙"都没想明白,后面全在返工。这个项目的核心模块其实只有五块。
2.1 五模块架构与各自职责
整个监控 Agent 我拆成了五个模块:
| 模块 | 职责 | 备注 |
|---|---|---|
| 采集模块 | 从目标博客抓取页面,提取标题、链接、发布时间、正文片段 | 优先找 RSS/feed 加速,没有就用页面解析 |
| 指纹管理模块 | 维护"已见文章"的 hash 集合,判断哪些是新文章 | 核心是稳定去重,避免短期重复 |
| 理解模块 | 用大模型对文章做主题分类、价值打分、摘要生成 | 这部分是 ReAct 循环里的"大动作" |
| 通知模块 | 组装简报,通过邮件或消息接口推送 | 按分类/紧急度做差异化模板 |
| 调度模块 | 管理整条流水线的时间轴与异常重试 | 最简单,但最容易忽略配置 |
这五个模块的关系不长篇大论画图了,简单描述:调度模块触发一轮采集,采集模块把新文章交给理解模块,理解模块在 ReAct 循环内部决定"这篇要不要细读、要不要摘要、要不要通知",最后走通知模块输出,同时指纹模块记录已处理文章的指纹。
2.2 传统监控方案为什么不如 Agent 灵活
有人会问:既然大部分博客有 RSS,我用现成的聚合工具加个关键词过滤不就行了?确实能解决一部分。但 RSS 的痛点在于它只能按"是否新增"做筛选,没法按"这篇是否真的值得我花三分钟"做筛选。
举一个实际遇到的例子:某团队博客发布了一篇季度复盘,标题里没有出现任何技术关键词,但正文聊了某个框架的设计取舍。RSS 标题过滤会直接漏掉;关键词过滤会把它当成无关内容跳过。但是在 ReAct 框架下,Agent 拿到文章之后可以先做一次"快速侦察":看看标题、看看元描述、看看正文开头,再判断需不需要深入读。这个过程可以被写成工具调用链,而不是一个死板的正则规则。
另一个优势是异常发现的能力。博客改版、文章回删、标题不变但内容更新(这种情况非常多),传统方案很难察觉,但 Agent 可以做"内容差异性判断":把这篇和上次保存的指纹比对,如果核心文本变了,就算同标题也重新走一遍摘要,这种灵活性只有语言模型给的出来。
2.3 数据流里的关键判断节点
这段是整个 Agent 跑起来之后,最容易失控的地方,值得提前说。一轮标准的数据流有四个判断节点:
- 新文章判定:指纹模块说"这篇没见过",才往下面走。
- 价值判定:理解模块用 Prompt 引导模型打分(1-10 分),低于阈值的直接跳过。
- 深度处理判定:如果分值中等,只生成一句话摘要;如果高分,做全文深度摘要 + 关键词提取。
- 通知判定:根据分值和分类决定走即时通知还是汇总简报。
这四个节点不是固定写死的,而是由 Agent 在 ReAct 循环里自主决定。为了让它"决定得靠谱",我在工具函数的设计上花了大量功夫,这部分是下一章的重点。
3. 手把手实现:从环境准备到核心循环代码
考虑到阅读对象是从零开始跑的开发者,这章我会把步骤拉得非常具体。环境版本我以当前的主流稳定版为准,你如果装了更新的小版本,大概率也能用。
3.1 环境准备:这一步容易翻车
我建议直接用虚拟环境,避免和系统 Python 打架。核心依赖如下:
python -m venv .venv source .venv/bin/activate pip install llama-index-core llama-index-readers-web llama-index-tools-requests pip install requests beautifulsoup4 lxml schedule openai pip install python-dotenv注意一个坑:llama-index 的生态拆分很细,单装llama-index主包拉起来很慢而且容易撞依赖,所以推荐按需安装。llama-index-readers-web是网页解析相关的读取器,llama-index-tools-requests用来封装自定义HTTP工具。
模型配置方面,我用的方式是将 API Key 写入.env文件,然后通过 dotenv 读取。这样代码里不会出现明文密钥,也方便切换不同的模型供应商。如果你没有 OpenAI 的接口,选其他兼容 OpenAI 风格的大模型 API 也可以,核心是 tools 调用能力要稳定。
3.2 核心工具函数设计:Agent 的"手脚"
ReAct 框架的精髓是给模型一组工具,让它思考"现在该调用哪个工具、下一步用什么参数"。所以工具函数决定了 Agent 能力的上限。我把这次实验的工具设计成了三个。
第一个是抓取新博客文章列表:
from typing import List, Dict import hashlib import requests from bs4 import BeautifulSoup def fetch_blog_posts(url: str) -> List[Dict]: """ 抓取博客首页或归档页,返回文章列表: 每一项包含 title, link, publish_date, snippet """ try: headers = {"User-Agent": "Mozilla/5.0 (compatible; BlogMonitor/1.0)"} resp = requests.get(url, headers=headers, timeout=15) resp.raise_for_status() except Exception as e: return {"error": f"fetch failed: {str(e)}"} soup = BeautifulSoup(resp.text, "html.parser") posts = [] # 这里的选择器比较保守,实际需要针对目标站点微调 for article in soup.select("article, .post, .entry"): title_tag = article.find("h1") or article.find("h2") or article.find("h3") link_tag = article.find("a") if not title_tag or not link_tag: continue title = title_tag.get_text(strip=True) link = link_tag.get("href") if not link.startswith("http"): link = requests.compat.urljoin(url, link) snippet = "" snippet_tag = article.find("p") if snippet_tag: snippet = snippet_tag.get_text(strip=True)[:200] posts.append({ "title": title, "link": link, "snippet": snippet, }) return posts这个函数看起来朴素,但有一个容易犯的错误:不同博客的 HTML 结构差异非常大,选择器写死的结果就是换一个站点就失效。为了解决这个问题,我加了一层"候选选择器列表",从宽到窄地匹配,宁可多抓点噪声也不能漏掉更新。
第二个是内容差异化判断与深度读取工具。因为很多博客首页只有摘要,需要点进去才能拿全文,所以我把"取单篇文章正文"和"取单篇文章元信息"分开设计,让 ReAct 循环按需调用:
def fetch_full_article(url: str) -> Dict: """抓取单篇文章的正文纯净文本,并计算一个内容指纹。""" resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) soup = BeautifulSoup(resp.text, "html.parser") for tag in soup(["script", "style", "nav", "footer", "aside"]): tag.decompose() content = soup.get_text(separator="\n", strip=True) # 只保留正文区域,简单做法:取字符最长的 div candidates = self_soup_find_main_content(soup) main_text = max(candidates, key=len, default="") main_text = main_text[:4000] # 控制 token 用量 fingerprint = hashlib.sha256(main_text.encode("utf-8")).hexdigest()[:16] return { "url": url, "content": main_text, "fingerprint": fingerprint, "content_length": len(main_text) }这里的关键是max(candidates, key=len),从页面上若干个可能承载正文的容器里选字符最长的那一个。这个方法不算完美,但实测在大多数技术博客上正确率在八成以上,比盲目用 main 标签或者纯粹的 get_text 都要稳。
第三个是轻量级摘要工具。注意这里我刻意没让模型做网站抓取,抓取全部交给上面的函数完成,模型只负责处理已经结构化后的文本——这样做的好处是减少模型在调用链中出错的机会,也就是把"会出错的动作"从模型手里拿走,放到确定性代码里。
def summarize_text(text: str, max_len: int = 200) -> str: """调用大模型接口生成摘要,供 Agent 在低价值场景使用。""" prompt = f"请用不超过{max_len}字的篇幅,总结下面文章的核心内容:\n\n{text}" # 这里以 OpenAI 风格接口为例 from openai import OpenAI client = OpenAI() resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=300 ) return resp.choices[0].message.content3.3 构建 ReAct Agent:给模型一个思考循环
工具函数齐了,接下来是把它们塞进 LlamaIndex 的 ReAct Agent 循环。LlamaIndex 的ReActAgentWorker允许我们定义 system prompt 和回调。
先看完整的 Agent 构建代码,再逐段解释:
from llama_index.core.agent import ReActAgent from llama_index.core.tools import FunctionTool from llama_index.core.llms import ChatMessage, MessageRole from llama_index.core.agent.react.types import ( ActionReasoningStep, ObservationReasoningStep, ) from llama_index.core.agent.react.output_parser import ReActOutputParser from llama_index.core.llms.types import ChatResponse # 1. 把工具变成 FunctionTool 对象 tool_fetch_posts = FunctionTool.from_defaults( fetch_blog_posts, name="fetch_blog_posts", description="抓取博客首页/归档页的文章列表。参数:博客URL。返回文章列表。" ) tool_fetch_article = FunctionTool.from_defaults( fetch_full_article, name="fetch_full_article", description="抓取单篇文章的纯净正文,并返回内容指纹。参数:文章URL。" ) tool_summarize = FunctionTool.from_defaults( summarize_text, name="summarize_text", description="对给定文本生成简短摘要。参数:文本内容。" ) # 2. 构建 ReAct Agent agent = ReActAgent.from_tools( tools=[tool_fetch_posts, tool_fetch_article, tool_summarize], llm=llm, # 这个 llm 是从 LlamaIndex 的 Settings 里构建的 max_iterations=15, verbose=True, ) # 3. 自定义 system prompt(可选,但强烈建议) SYSTEM_PROMPT = """你是博客监控助手。你的任务是判断给定博客是否有值得关注的新内容。 请按以下步骤工作: 1. 用 fetch_blog_posts 获取文章列表。 2. 如果发现了可能与【大模型应用、自动化工具、技术方案】相关的文章,调用 fetch_full_article 获取正文。 3. 判断文章价值:高分文章直接输出摘要;低分文章放弃;中等文章用 summarize_text 生成短摘要。 4. 所有调用必须给出明确的工具参数,不要在思考过程中编造工具结果。 尽可能少调用工具,但保证关键信息不遗漏。 """ chat_engine = agent.chat # 会话入口几个容易出问题的点,逐个说:
max_iterations=15的意思是 ReAct 循环最多转 15 轮。如果模型反复思考但迟迟不输出最终答案,循环会被强制结束。这里设太小会在跑长流程时失败,设太大又可能失控,15 是我测试下来比较稳的数字。verbose=True在开发阶段非常有用,能把模型的"Thought / Action / Observation"链完整打出来,方便调试。生产环境建议关掉,只保留结构化日志。- system prompt 里面有两个关键的限定:一是明确"不要在思考过程中编造工具结果",这是 ReAct 循环最容易出现幻觉的地方;二是"尽可能少调用工具",这能省一大笔 token 成本。
3.4 调度器与持久化:让它真正"全自动"
Agent 循环本身不附带任何定时任务能力,需要自己搭一个调度器。我用的是schedule库,配合一个 SQLite 或本地 JSON 文件做持久化:
import schedule import json import time from pathlib import Path HISTORY_FILE = "history.json" def load_history() -> dict: if Path(HISTORY_FILE).exists(): return json.loads(Path(HISTORY_FILE).read_text(encoding="utf-8")) return {"seen_fingerprints": {}, "last_run": None} def save_history(history: dict): Path(HISTORY_FILE).write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8") def run_monitor_cycle(): history = load_history() blogs = [ {"name": "某技术博客", "url": "https://example-blog.com/archives"}, {"name": "某框架团队博客", "url": "https://example-frame.org/blog"}, ] digest_lines = [] for blog in blogs: result = agent.chat(SYSTEM_PROMPT + f"\n\n请检查博客 {blog['name']} ({blog['url']})") # 将本轮产生的指纹记录到历史 if hasattr(agent, "last_fingerprints"): for fp in agent.last_fingerprints: history["seen_fingerprints"][fp] = time.time() digest_lines.append(result.response) history["last_run"] = time.time() save_history(history) send_digest("\n\n".join(digest_lines)) # 每 6 小时跑一次 schedule.every(6).hours.do(run_monitor_cycle) while True: schedule.run_pending() time.sleep(60)这段代码最大的坑是:如果某次运行 Agent 内部异常,history文件可能被污染。所以我建议先将历史记录写到临时文件,全部运行完成后再原子替换。更深一层的问题是去重不该完全依赖模型,因为模型可能漏报。我更推荐在工具函数里直接检查指纹是否已经存在,把"去重"从模型手里拿回来变成硬逻辑,也就是在run_monitor_cycle的循环里手动对比指纹,而不是让模型自己记。
4. 实测运行观察:一段真实监测日志逐行解读
写代码的人都知道,真正的问题都在"运行起来之后"才会暴露。这一章我用一段实测日志来讲讲 ReAct Agent 实际跑起来是什么样子,它为什么有时候表现得像在"打太极"。
4.1 第一波日志:判断链路正常吗
先看一次成功的运行日志(脱敏简化):
Thought: 我先获取目标博客的文章列表,确认今天有没有新增文章。 Action: fetch_blog_posts Action Input: {"url": "https://example-blog.com/archives"} Observation: 返回3篇文章。最近一篇标题是"部署大型语言模型的五种路径"。 Thought: 这篇文章的标题和大模型部署直接相关,符合我的监控重点,需要抓全文。 Action: fetch_full_article Action Input: {"url": "https://example-blog.com/deploy-llm-paths"} Observation: 成功获取正文,内容指纹为 3f2a1c01d9b4 Thought: 指纹和之前记录不一致,是新文章。判断价值较高,生成深度摘要。 Action: summarize_text Action Input: {"text": "...前1000字..." } Observation: 摘要输出完成,内容涉及多条部署路径对比。 Answer: 该博客有新增文章,值得关注,摘要为:……哪个环节最有价值?其实是那个"判断指纹不一致"的动作。ReAct Agent 在这里做了一次主动的条件判断,这是我预期的设计——但它做对了一个关键动作:先看指纹再决定是否调用summarize_text,避免了重复处理。
但我还观察到一个意料之外的行为:它没有把"指纹不一致"这件事写进自己的长期记忆。也就是说,这次运行后,下一次运行它并不知道自己见过这篇。这说明 Agent 的"记忆"完全依赖外部状态存储,不能指望模型内部记东西。所以我在运行完之后的代码里强制把所有新指纹同步到了history.json,再下次跑的时候,工具函数会先过滤掉这些已见文章。
4.2 第二波日志:当模型开始"偷懒"
还有一个很典型的失败模式:
Thought: 博客列表只有一篇旧文章,没有新内容,直接回答。 Action: fetch_blog_posts Observation: 返回2篇文章。 Thought: 第二篇标题是"某开源项目季度报告",看起来可能相关,但我感觉不太重要,不必深入。 Answer: 没有重要新文章。这就是问题所在。第二篇文章标题里没出现任何我预定义的关键词,模型就"感觉"不重要,直接不调用工具。但实际上正文里讲了一个关键库的升级改动,属于高价值内容。这个案例让我意识到:Agent 的价值判断太依赖它的初始 prompt 语气,而 prompt 的语气又太容易把模型带向"保守省事的模式"。
解决办法是在 system prompt 里增加一条:
对于标题不明确的文章,至少调用一次 fetch_full_article 再判断价值。加了这个约束之后,漏报率显著下降。但要小心副作用:对大列表博客,每篇文章都去抓全文,token 消耗会增加三到五倍,所以我还加了二次判断:只有"标题包含技术关键词或标题不含任何明确关键词但长度在 10 个字以上"的文章才会触发全文抓取。这种折中方案比单纯依赖模型直觉更可控。
4.3 评估工程质量的三条硬指标
跑了一周之后,我给自己定下三条硬指标,这里分享给你,评估自己的监控 Agent 也可以用:
- 漏报率:在人工抽查的 50 篇新增文章中,Agent 漏掉的数量应该少于 3 篇(漏掉指完全没有出现在任何摘要中)。
- 误报率:在它主动推送的所有摘要里,我判断"毫无兴趣"的比例应低于 20%。
- 循环失控率:单轮任务因为超过
max_iterations而强制中断的次数应低于 5%。
这三条指标比"Agent 能回答问题"重要得多,因为监控工具的本质是持续运行和精准触发,而不是聊天。
5. 调试、避坑与性能优化:我在复现中踩过的坑
如果说前面是"手把手带你搭骨架",最后这一章就是我在实验里真正花时间最多的地方。这里面的坑在网上很少被系统总结,我按踩坑频率从高到低列出来。
5.1 模型不会用长文本工具参数,导致循环卡死
这是最频繁出现的问题。当博客列表很长,模型拿到fetch_blog_posts返回的十几篇文章后,经常会出现"幻觉调用"——比如给fetch_full_article传一个根本不存在的 URL,然后Observation返回 error,模型又判断说"我再试一次",于是循环空转。
我的修复方式是三层:
- 工具函数内做强校验,URL 必须与目标博客域名匹配才放行。
- 增加一个
validate_article_link工具,专门用来校验链接是否属于可访问的正文页而不是分类页——这看起来多余,但在防止幻觉调用上非常有效。 - 在 system prompt 里显式声明:"如果工具返回 error,不要重试超过一次,直接跳到下一篇文章。"
这个在 ReAct 框架里等价于"任务约束外挂到工具设计里",模型再怎么绕也绕不出去。
5.2 token 消耗失控:频繁调用大模型的成本
监控类 Agent 每小时跑一轮,每轮如果处理 10 篇文章,每篇抓全文 4000 字,调用摘要 3-5 次,一天的 token 消耗非常可观。我试过最笨的版本,一天烧掉几十万 token。后来改了三个地方:
- 用轻量模型做过滤,用重量模型做精摘。低价值判断用快而便宜的模型,只有分值高的文章才交给更强模型做深度摘要。
- 摘要调用之前,先做文本长度压缩。正文中超过 4000 字符的部分直接丢弃,只保留开头部分和结尾部分,因为技术博客的核心信息基本都在前两屏和总结段。
- 相邻两次运行之间共享 fingerprint。上次已经摘要过的文章,这次即便标题变了但正文指纹没变,就直接跳过。
这些调整之后,日均 token 消耗降到了原来的 15% 左右,结论质量没有明显下降。
5.3 站点改版/结构变化导致选择器失效
前面说过,依靠 CSS 选择器抓取博客天生脆弱。某天目标博客改版后,<article>标签被替换成了自定义的<post-card>组件,我的解析函数直接抓不到任何文章,Agent 还傻傻地输出"该博客没有新文章"。
解决思路有两个方向:第一,在采集模块里增加"健康检查"——如果提取结果为 0 条,就发一条告警通知,至少在结构变化时你第一时间知道;第二,把选择器尽量通用化,不只依赖标签,而是结合"包含链接的标题文本"这一特征。相对位置匹配法也可以,比如"页面里包含datetime属性或time标签的容器,往上找包含链接的标题文本",这种策略能容忍更多改版形式。
5.4 时区问题导致的"昨天的文章被当今天的新文章"
博客文章的时间格式五花八门,有带时区偏移的,有不带的;有的发布时间存在 JSON-LD 里,有的只是一个纯文本。如果不做统一处理,Agent 很容易出现"把旧文章当新增"的情况。
我的方案是在采集模块里定义一个统一的规范时间解析函数,把各种格式全部转成 UTC 时间戳;然后对每篇文章生成"时间 + 标题 + 链接"的组合指纹,而不仅仅是正文指纹。这样既避免重复,又保证了"同标题同文但发布时间发生变化"的情况也能被识别出来,后续可以在摘要里提示"这篇文章被更新过"。
5.5 模型输出格式不稳定时的兜底解析
最后一次提一个在 ReAct 实现里非常容易踩的坑:ReActOutputParser依赖模型的输出遵循严格的格式才能拆出Action、Action Input、Answer。但模型偶尔会输出带 Markdown 格式的 JSON,或者把Action写成Action2,导致Observation无法匹配,最终抛出解析异常。
我的兜底做法是:在ReActAgentWorker外层包了一层异常捕获器,当解析失败时,不直接让 Agent 挂掉,而是把原始输出保存下来,等待下一轮循环用"重问一次"的方式继续。具体实现可以在 agent 顶部包一个while True / try,或者用 LlamaIndex 自带的retry_parse。要记住:Agent 工程的韧性靠的是兜底,不是靠模型永远不出错。
坦白说,这个 Agent 第一次稳稳当当跑满一周的时候,我盯着日志看了好几遍,有种"这玩意儿居然真的在替我干活"的错觉。后面每次调优,我最深的体会是:所谓全自动,并不是把什么都交给模型,恰恰相反,是把能确定的事情全部用代码固化下来,只在真正需要判断的地方放给模型。博客监控的难点从来不在"有没有新文章",而在"哪些新文章值得看、怎么解释为什么值得看"。ReAct 框架真正帮到我的,是让模型能在我设定的工具边界内,自己走完那几步思考路径。
如果你打算照着做,我的建议是从一个小清单开始:先接三五个你真正常读的博客,跑 24 小时,看看日志里 Agent 自己总结出来的行为模式,再慢慢加站点。不要一开始就追求大而全的监控矩阵。等它跑顺了,你还可以继续扩展别的工能,比如让 Agent 在摘要里标注"这篇适合收藏、这篇适合转发给团队",甚至加上"是否与当前项目相关"的标签。到那时候你会发现,监控 Agent 的本质其实不是监控,而是帮你把"每天打开一堆网页判断看不看"这件事转化为一条安静的推送流水线——这比任何自动化工具带来的爽感都更持久。