RAG 每日一技(二十二):文档里没有的,别让模型“编”——给RAG接上工具路由

53 阅读5分钟

上一回我们让答案“会算账”,用计算计划和白名单执行把增长率算得明明白白。可有些问题天生不在文档里,比如“现在的美元兑人民币是多少”“明天上海下不下雨”“本周是否交易日”。如果还指望向量检索或让模型“猜”,翻车只会更频繁。正确姿势是让RAG学会“请教外援”:当问题需要实时数据或可计算结论时,路由到工具;拿到结果后仍按“可审计”的方式合回答案。

先把工具抽象出来。工具不是一段随口生成的文字,而是一份“收据”:值、单位、时间戳、来源。下面用三个玩具工具占个位(汇率、天气、交易日),实现上用假的返回值,但把“收据”格式定死,这样未来替换为真实API也无需改编排。

from datetime import datetime, timezone, timedelta

def utcnow_iso():
    return datetime.now(timezone.utc).isoformat()

def tool_fx_rate(base: str, quote: str):
    # 假数据:只演示结构
    return {
        "tool": "fx_rate",
        "params": {"base": base, "quote": quote},
        "value": 7.1234,
        "unit": f"{base}/{quote}",
        "timestamp": utcnow_iso(),
        "source": {"name": "demo-fx", "endpoint": "/fx", "ttl_sec": 600}
    }

def tool_weather(city: str, day_offset: int = 0):
    return {
        "tool": "weather",
        "params": {"city": city, "day_offset": day_offset},
        "value": {"temp_c": 26, "cond": "Cloudy"},
        "unit": {"temp_c": "°C"},
        "timestamp": utcnow_iso(),
        "source": {"name": "demo-weather", "endpoint": "/forecast", "ttl_sec": 900}
    }

def tool_is_trading_day(market: str, date_iso: str):
    return {
        "tool": "trading_calendar",
        "params": {"market": market, "date": date_iso},
        "value": {"is_trading_day": True},
        "unit": None,
        "timestamp": utcnow_iso(),
        "source": {"name": "demo-calendar", "endpoint": "/is_open", "ttl_sec": 86400}
    }

这里最重要的不是那几个“假数”,而是“收据”的完整性:任何工具都要交代它是谁、查了什么参数、什么时候查的、结果单位是什么、结果多久过期。你把这个约束放在接口上,后面换真API时,可靠性自然落在地上。

路由怎么做?别上来就把决策交给大模型,先用便宜的启发式把“显而易见”的问题拦住,再把模糊的问法交给 LLM 辅判。下面的版本先用正则触发“tool”,否则按你在第十九篇打好的“text/table”通道走。

import re
from datetime import date

RE_FX = re.compile(r"(汇率|兑|exchange rate|USD/?CNY|美元对?人民币)", re.I)
RE_WEATHER = re.compile(r"(天气|气温|下雨|降雨|晴|多云)", re.I)
RE_TRADING = re.compile(r"(交易日|开市|休市)", re.I)

def route(query: str) -> str:
    if RE_FX.search(query) or RE_WEATHER.search(query) or RE_TRADING.search(query):
        return "tool"
    # 复用你已有的文本/表格路由
    # if looks_numeric: return "table"
    return "text"

def choose_tool(query: str):
    if RE_FX.search(query):
        return ("fx_rate", {"base":"USD","quote":"CNY"})
    if RE_WEATHER.search(query):
        city = "上海" if "上海" in query else "北京"
        day_offset = 1 if "明天" in query else 0
        return ("weather", {"city": city, "day_offset": day_offset})
    if RE_TRADING.search(query):
        return ("trading_calendar", {"market":"SSE", "date": date.today().isoformat()})
    return (None, None)

路由的关键是“先粗后精”。规则能覆盖八成场景,剩下的两成再让 LLM 来判断是否需要工具,并在必要时补全参数(比如把“港股今天开盘吗”改写成 market=HKEX)。但不管谁拍板,最后都要回到结构化调用上来,避免“模型自由发挥”。

把编排串起来:问题进来,路由选择;是“tool”就调用工具并生成一段受约束的回答模板,顺带把“收据”塞进返回体;不是“tool”就走你现有的文本/表格通道。示例里用字符串拼接代替真正的 LLM 生成,重点放在数据如何组织。

def answer(query: str):
    path = route(query)
    if path == "tool":
        tool, params = choose_tool(query)
        if tool == "fx_rate":
            receipt = tool_fx_rate(**params)
            text = f"当前{receipt['unit']}约为 {receipt['value']:.4f}(UTC {receipt['timestamp']})。"
        elif tool == "weather":
            receipt = tool_weather(**params)
            v = receipt["value"]
            text = f"{params['city']}预报:{v['cond']},约 {v['temp_c']}{receipt['unit']['temp_c']}(UTC {receipt['timestamp']})。"
        elif tool == "trading_calendar":
            receipt = tool_is_trading_day(**params)
            flag = "是" if receipt["value"]["is_trading_day"] else "否"
            text = f"{params['market']}{params['date']} {flag}交易日(UTC {receipt['timestamp']})。"
        else:
            return {"answer":"根据提供的资料,我无法回答该问题。","citations":[], "tool_receipts":[]}
        return {"answer": text, "citations": [], "tool_receipts": [receipt]}
    # 非工具问题:复用你的文本/表格RAG
    # result = answer_with_doc_or_table(query)  # 略
    return {"answer":"(此处走文本/表格RAG通道)","citations":[/* ... */], "tool_receipts":[]}

这里的输出格式和你在第二十篇“可点击引用”里定义的响应体保持一致,只是新增了一个 tool_receipts 字段。前端拿到它,不需要猜测含义;直接展示“来源、时间、参数、值”,并在答案末尾做一行“数据来自××,UTC ××,有效期××”,就完成了“可审计的实时数据”。如果结果进入了第二十一篇的“计算计划”,把工具值当作输入 a/b/... 继续算即可,引用仍然可追。

再看两个调用的实际效果。现在问“美元兑人民币多少”,你会得到一句简洁的结论,后面带着UTC时间;问“明天北京天气怎么样”,你会得到天气现象与温度,并且能在返回体里看到是哪个工具、什么参数、何时调用。把这套数据和第二十篇的“可点击引用”放在一起,一边点文档段落高亮,一边看外部数据的收据,你的答案就从“像是有出处”进化为“文档证据+外部事实双可验”。

最后补三句话,都是踩坑换来的。第一句,所有工具调用都要有 TTL(过期时间),生成端看到过期就必须提示“可能已不再最新,点击刷新”;第二句,统一时区和单位,把“上海明天”解释成具体日期,把“美元”解释成 ISO 货币代码;第三句,永远保留“我不知道”的退路——当工具失败或超时,不要让模型凭空补数据,答案里明确写“外部数据暂不可用”。做到这三条,你的工具路由就不会成为新的幻觉源头。

今天我们把“文档没有的事实”请工具来回答,并且把工具结果也纳入“可审计”的返回格式。下一篇换个方向,不再谈外部接口,回到检索本身,解决另一个老问题:Top-k 里经常“同质化”,导致上下文重复而丢失关键信息。我们会用最大边际相关(MMR)和多样性采样,让送进模型的证据更少重复、更多信息量。