网页检索问答

模型只"知道"训练截止前的事,要回答新问题、查实时资料,就得让它先上网搜。本章实现一条完整链路:给智能体加 search_web(搜索)与 fetch_web(抓正文)两个工具,用标准库清洗网页正文,把检索结果喂给模型并要求给出来源 URL。

search_web:搜索工具

示例用维基百科的开放搜索接口当"搜索引擎"——稳定、免费、无需 key、返回 JSON。正式项目把它换成你买的搜索 API(如必应、博查等),函数签名不变:

# agent_demo/web_tools.py —— 第 14 章
import os
import requests
from urllib.parse import quote

SEARCH_API = os.environ.get("SEARCH_API",
                            "https://zh.wikipedia.org/w/api.php")

def search_web(query: str, top_k: int = 3) -> str:
    """网页搜索,返回标题 + 摘要 + 链接,供模型或后续抓取使用。"""
    params = {"action": "query", "list": "search", "srsearch": query,
              "srlimit": top_k, "format": "json", "utf8": 1}
    r = requests.get(SEARCH_API, params=params, timeout=10)
    r.raise_for_status()
    hits = r.json()["query"]["search"]
    if not hits:
        return "没有搜到相关内容"
    lines = []
    for i, h in enumerate(hits, 1):
        url = "https://zh.wikipedia.org/wiki/" + quote(h["title"])
        lines.append(f"{i}. {h['title']}\n   {h['snippet']}\n   {url}")
    return "\n".join(lines)

srlimit 限制条数、srsearch 是关键词;snippet 里可能残留 HTML 标签,正好用下面的清洗函数处理(示例中把清洗用在抓正文上,也可顺手套在摘要上)。

正文清洗:标准库 html.parser

抓到的网页是 HTML,直接喂给模型又费 token 又容易读偏。写一个只留正文的提取器(跳过 script/style,块级标签处换行):

# agent_demo/web_tools.py(续)—— 正文清洗
from html.parser import HTMLParser

class _Text(HTMLParser):
    SKIP = {"script", "style", "nav", "header", "footer"}
    def __init__(self):
        super().__init__()
        self.parts, self.skip = [], 0
    def handle_starttag(self, tag, attrs):
        if tag in self.SKIP:
            self.skip += 1
        if tag in ("p", "div", "li", "br", "h1", "h2", "h3"):
            self.parts.append("\n")
    def handle_endtag(self, tag):
        if tag in self.SKIP and self.skip:
            self.skip -= 1
    def handle_data(self, data):
        if not self.skip:
            self.parts.append(data)

def clean_html(html: str, max_len: int = 2000) -> str:
    """把 HTML 变成可读纯文本,最多保留 max_len 字符。"""
    p = _Text()
    p.feed(html)
    text = "".join(p.parts)
    text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
    return text[:max_len]

fetch_web:抓正文工具

抓取时带上 User-Agent(部分站点对空 UA 返回 403),解析失败/超时都返回一句人话,而不是抛异常:

def fetch_web(url: str, max_len: int = 2000) -> str:
    """抓取网页纯文本正文,供模型当参考资料。"""
    try:
        r = requests.get(url, timeout=10,
                         headers={"User-Agent": "Mozilla/5.0 (kb-agent demo)"})
        r.raise_for_status()
        r.encoding = r.apparent_encoding or "utf-8"
        return clean_html(r.text, max_len)
    except requests.exceptions.RequestException as e:
        return f"抓取失败:{e}"

清洗结果离线即可验证(先生成一个本地测试页):

html = """<html><body><h1>网页检索测试</h1>
<p>智能体通过 <b>工具</b> 获得外部信息。</p>
<script>var secret = 1;</script></body></html>"""
print(clean_html(html))
# 输出:网页检索测试
# 输出:智能体通过 工具 获得外部信息。

script 被整段剔除,标签与多余空白被清理,剩下的正是要喂给模型的正文。

问答流程:检索 → 组装上下文 → 带来源回答

把两个工具串成"搜索+回答"函数:搜到候选后,摘录标题/摘要与链接作为上下文,并要求模型引用来源:

# demo_web_rag.py —— 第 14 章 完整流程(需 .env,见第 2 章)
from llm_client import LLMClient       # 沿用第 2 章客户端
from web_tools import search_web, fetch_web

llm = LLMClient()

def answer_with_sources(question: str) -> str:
    hits = search_web(question)                 # 1) 搜索候选
    if hits.startswith("没有搜到"):
        return "没检索到相关资料,建议换个说法再问。"
    first_url = next(l.strip() for l in reversed(hits.splitlines()) if l.strip())
    # 上面取最后一行的链接(URL 每行只有一个,且排在条目的最后一行)
    body = fetch_web(first_url)                 # 3) 抓正文做上下文(可选,省 token 可省)
    context = (f"检索摘要:\n{hits}\n\n正文片段(来自 {first_url}):\n{body}")
    system = ("你是资料检索助手。只依据上面给出的资料回答;"
              "末尾用一行给出来源:来源:标题 —— URL。资料不足就明说不知道。")
    return llm.chat_text([{"role": "system", "content": system},
                          {"role": "user",
                           "content": f"问题:{question}\n\n{context}"}])

输出效果(实际内容以模型与检索结果为准):

# 输出(示例):
# 用户:什么是 RAG?
# 助手:RAG(检索增强生成)是让大模型先从外部资料库检索相关内容,
# 再结合这些内容生成回答的技术,能减少幻觉、补充最新知识。
# 来源:检索增强生成 —— https://zh.wikipedia.org/wiki/检索增强生成

把 search_web、fetch_web 用第 5 章 @tool 装饰器注册后,第 8 章 run_agent 循环里模型会自己决定"什么时候搜、搜几条"——多跳问题(先搜概念再追细节)也能通过连续工具调用完成,工具内部仍然复用上面的检索与清洗函数。

没有公网时的联调办法

search 接口换成能访问的即可;若只想联调"抓取→清洗→回答"这段,起个本地静态页当抓取目标:

# 终端 1:在放有测试 HTML 的目录起静态服务
python -m http.server 8000
# 终端 2:把 fetch_web 指向本地页面验证清洗与回答链路

小结:网页检索问答 = search_web 搜候选 + clean_html/fetch_web 抓正文并清洗 + 把"摘要与正文"拼进提示词让模型基于资料作答并附来源;搜索接口可替换为你可访问的服务,断网时用本地页面联调抓取与清洗链路。

笔记加载中…