LlamaIndex ReAct实战:构建全自动博客监控Agent 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, headersheaders, timeout15) resp.raise_for_status() except Exception as e: return {error: ffetch 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(stripTrue) 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(stripTrue)[: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}, timeout15) soup BeautifulSoup(resp.text, html.parser) for tag in soup([script, style, nav, footer, aside]): tag.decompose() content soup.get_text(separator\n, stripTrue) # 只保留正文区域简单做法取字符最长的 div candidates self_soup_find_main_content(soup) main_text max(candidates, keylen, 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, keylen)从页面上若干个可能承载正文的容器里选字符最长的那一个。这个方法不算完美但实测在大多数技术博客上正确率在八成以上比盲目用 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( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0.3, max_tokens300 ) 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, namefetch_blog_posts, description抓取博客首页/归档页的文章列表。参数博客URL。返回文章列表。 ) tool_fetch_article FunctionTool.from_defaults( fetch_full_article, namefetch_full_article, description抓取单篇文章的纯净正文并返回内容指纹。参数文章URL。 ) tool_summarize FunctionTool.from_defaults( summarize_text, namesummarize_text, description对给定文本生成简短摘要。参数文本内容。 ) # 2. 构建 ReAct Agent agent ReActAgent.from_tools( tools[tool_fetch_posts, tool_fetch_article, tool_summarize], llmllm, # 这个 llm 是从 LlamaIndex 的 Settings 里构建的 max_iterations15, verboseTrue, ) # 3. 自定义 system prompt可选但强烈建议 SYSTEM_PROMPT 你是博客监控助手。你的任务是判断给定博客是否有值得关注的新内容。 请按以下步骤工作 1. 用 fetch_blog_posts 获取文章列表。 2. 如果发现了可能与【大模型应用、自动化工具、技术方案】相关的文章调用 fetch_full_article 获取正文。 3. 判断文章价值高分文章直接输出摘要低分文章放弃中等文章用 summarize_text 生成短摘要。 4. 所有调用必须给出明确的工具参数不要在思考过程中编造工具结果。 尽可能少调用工具但保证关键信息不遗漏。 chat_engine agent.chat # 会话入口几个容易出问题的点逐个说max_iterations15的意思是 ReAct 循环最多转 15 轮。如果模型反复思考但迟迟不输出最终答案循环会被强制结束。这里设太小会在跑长流程时失败设太大又可能失控15 是我测试下来比较稳的数字。verboseTrue在开发阶段非常有用能把模型的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(encodingutf-8)) return {seen_fingerprints: {}, last_run: None} def save_history(history: dict): Path(HISTORY_FILE).write_text(json.dumps(history, ensure_asciiFalse, indent2), encodingutf-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 的本质其实不是监控而是帮你把每天打开一堆网页判断看不看这件事转化为一条安静的推送流水线——这比任何自动化工具带来的爽感都更持久。