上一回我们让答案“会算账”,用计算计划和白名单执行把增长率算得明明白白。可有些问题天生不在文档里,比如“现在的美元兑人民币是多少”“明天上海下不下雨”“本周是否交易日”。如果还指望向量检索或让模型“猜”,翻车只会更频繁。正确姿势是让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)和多样性采样,让送进模型的证据更少重复、更多信息量。