Files
ai-agent-book/chapter4/active-tool-discovery/offline_backend.py
T
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

196 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
离线后端:让整条流水线在**没有 OpenAI key** 时也能跑通,用于验证机制、
量化 token/延迟,并让读者零成本复现"三种策略"的对比结构。
包含两部分:
1) LocalEmbedder —— 本地哈希词袋嵌入(中文字 unigram/bigram + 英文词),
无需联网即可支撑 discover_tools / 检索预筛选的语义相似度。
2) MockChatClient —— 一个确定性的"脚本化"模型,接口与 OpenAI 客户端一致
client.chat.completions.create(...).choices[0].message.content)。
它按关键词把任务拆成若干子任务,遵循 ReAct 文本协议逐步调用工具。
重要边界说明:
- MockChatClient 是一个**强启发式路由器**,不代表真实小模型的能力,因此它**不会**复现
书中"超长上下文下指令遵循退化、错选通用工具"的现象——那需要真实的小参数量模型。
- 离线模式下真实可复现的是:① 各策略注入的 token 量(tiktoken 真实计算);
② 检索预筛选"一次性匹配"的结构性局限(若第二个子任务的专用工具没被初始检索选中,
模型就调用不到它 → 该子任务失败);③ 主动发现按需加载后仍能补齐工具、完成任务。
- 要观察真实模型在长上下文工具墙下的选择行为,请配置真实模型(见 README 中 gpt-5.6-luna 的真实结果表)。
"""
import hashlib
import json
import re
from types import SimpleNamespace
from typing import Dict, List, Tuple
from tools_library import TOOLS_BY_NAME
_DIM = 512
# ---------------------------------------------------------------------------
# 1) 本地嵌入后端
# ---------------------------------------------------------------------------
def _tokens(text: str) -> List[str]:
"""把中英文混合文本切成词袋 token:英文按词(并拆下划线),中文按字 unigram + bigram。"""
text = text.lower()
toks: List[str] = []
for w in re.findall(r"[a-z0-9]+", text):
toks.append(w)
han = re.findall(r"[一-鿿]", text)
toks += han
toks += [han[i] + han[i + 1] for i in range(len(han) - 1)]
return toks
class LocalEmbedder:
"""哈希词袋嵌入:确定性、无需联网。相似度由中英文关键词重叠驱动。"""
name = "local-hash-%d" % _DIM
def embed(self, texts: List[str]) -> List[List[float]]:
out = []
for t in texts:
vec = [0.0] * _DIM
for tok in _tokens(t):
h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
vec[h % _DIM] += 1.0
norm = sum(x * x for x in vec) ** 0.5 or 1.0
out.append([x / norm for x in vec])
return out
# ---------------------------------------------------------------------------
# 2) 脚本化 mock 模型
# ---------------------------------------------------------------------------
# 意图规则:把任务关键词映射到"应当使用的专用工具"及一句能力需求描述。
# 顺序有意义(如"预报"类天气须排在通用"天气"之前)。
INTENT_RULES: List[Tuple[str, str, str]] = [
(r"股价|股票", "get_stock_price", "查询某股票的实时价格与涨跌幅"),
(r"以太坊|比特币|加密|\beth\b|\bbtc\b", "get_crypto_price", "查询加密货币的实时价格"),
(r"日元|汇率|美元.*换|换.*(日元|美元|欧元)|兑换", "get_forex_rate", "查询两种法定货币的外汇汇率"),
(r"论文|arxiv|文献|量子计算|科研进展|研究进展", "arxiv_search", "在学术论文库检索最新论文"),
(r"下载", "download_file", "从 URL 下载文件保存到本地"),
(r"贡献", "github_list_contributors", "获取 GitHub 仓库的贡献者提交统计"),
(r"图表|可视化|画个|画图|画一", "render_chart", "根据数据渲染可视化图表"),
(r"预报|未来|周日|这周|明天|后天|下周", "get_weather_forecast", "查询某城市未来若干天的天气预报"),
(r"天气", "get_current_weather", "查询某城市的实时天气"),
(r"日历|日程|活动|记一个|记录一个", "create_calendar_event", "在日历上创建一个事件"),
(r"新闻|舆论|消息|报道|风向", "search_news", "按关键词检索相关的最新新闻"),
]
def match_intents(prompt: str) -> List[Tuple[str, str]]:
"""返回任务涉及的 (专用工具名, 能力需求描述) 列表(去重、保序)。"""
needed: List[Tuple[str, str]] = []
seen = set()
for pat, tool, phrase in INTENT_RULES:
if re.search(pat, prompt, re.IGNORECASE) and tool not in seen:
needed.append((tool, phrase))
seen.add(tool)
# 天气去重:若命中"预报"则不再单独要求"实时天气"。
if "get_weather_forecast" in seen and "get_current_weather" in seen:
needed = [(t, p) for t, p in needed if t != "get_current_weather"]
return needed
_ARG_HINTS = {
"symbol": "AAPL", "location": "北京", "query": "查询", "url": "https://example.com/f.pdf",
"path": "/tmp/paper.pdf", "owner": "pytorch", "repo": "pytorch", "base": "USD",
"quote": "JPY", "title": "户外徒步", "start": "2026-07-19T09:00", "end": "2026-07-19T12:00",
"days": 3, "data": "[]", "chart_type": "bar", "code": "print('ok')", "max_results": 3,
}
def _fill_args(tool_name: str) -> Dict:
tool = TOOLS_BY_NAME.get(tool_name)
if not tool:
return {}
props = tool["function"]["parameters"]["properties"]
args = {}
for key, spec in props.items():
if key in _ARG_HINTS:
args[key] = _ARG_HINTS[key]
elif spec.get("type") == "integer":
args[key] = 1
else:
args[key] = "auto"
return args
def _extract_json(text: str):
text = text.strip()
start = text.find("{")
if start == -1:
return None
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except json.JSONDecodeError:
return None
return None
def _json(thought: str, tool: str, arguments: Dict) -> str:
return json.dumps({"thought": thought, "tool": tool, "arguments": arguments},
ensure_ascii=False)
class MockChatClient:
"""确定性脚本模型;接口与 OpenAI 客户端子集兼容。"""
def __init__(self):
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
def _create(self, model=None, messages=None, temperature=0, **kw):
content = self._respond(messages or [])
msg = SimpleNamespace(content=content)
return SimpleNamespace(choices=[SimpleNamespace(message=msg)])
def _respond(self, messages: List[Dict]) -> str:
system = messages[0]["content"] if messages and messages[0]["role"] == "system" else ""
task_prompt = next((m["content"] for m in messages if m["role"] == "user"), "")
full_text = "\n".join(m.get("content", "") for m in messages)
has_discover = "discover_tools" in system
# 当前"可用工具" = 出现在对话文本中的工具名(system 注入 / discover 追加)。
available = set(re.findall(r'"name":\s*"([a-zA-Z_][a-zA-Z0-9_]*)"', full_text))
available.discard("discover_tools")
prior = []
for m in messages:
if m["role"] == "assistant":
a = _extract_json(m.get("content", ""))
if a and "tool" in a:
prior.append(a)
called_ok = {a["tool"] for a in prior if a["tool"] in available}
discover_needs = [((a.get("arguments") or {}).get("need", ""))
for a in prior if a.get("tool") == "discover_tools"]
attempted = [a["tool"] for a in prior
if a["tool"] not in available and a["tool"] not in ("discover_tools", "finish")]
for tool, phrase in match_intents(task_prompt):
if tool in called_ok:
continue
if tool in available:
return _json(f"调用专用工具 {tool}", tool, _fill_args(tool))
# 目标工具当前不可用
if has_discover:
if discover_needs.count(phrase) >= 1:
continue # 已发现过仍未命中 -> 放弃该子任务
return _json(f"我需要一个能『{phrase}』的工具,先发现它", "discover_tools",
{"need": phrase})
else:
if attempted.count(tool) >= 1:
continue # 清单里没有该工具,尝试过一次即放弃
return _json(f"任务需要 {tool},尝试调用", tool, _fill_args(tool))
return _json("所有子任务已处理", "finish", {"answer": "已完成可完成的子任务。"})