项目地址:https://github.com/yese6g/S_Wolf
这个项目最初的目标很小:做一个完全本地运行、能接自己的 API Key、能切换本地 Ollama 的AI 助手,前端不依赖任何 CDN,后端不依赖任何外部服务。做完第一版之后陆续加上了工具调用、知识库、长期记忆、文件沙箱、任务规划和权限审批,现在是一个带 22 个工具的 Agent 工作台。这篇文章记录实现层面的决策与细节:分层方式、Agent 循环的每一处边界条件、上下文与 token成本的计算方法、搜索结果的过滤策略、RAG 的离线 embedding 兜底、权限沙箱的实现,以及测试方案。所有代码片段都来自仓库当前版本,数据来自实际测量。
一、技术选型与项目规模
| 部分 | 选型 | 说明 |
|---|---|---|
| 后端 | FastAPI + uvicorn | main.py 挂载路由与静态资源,SilverWolf.py 是唯一的路由层 |
| 会话存储 | SQLite | 单文件 chat_history.db,会话/消息/附件三张表 |
| 向量库 | ChromaDB(PersistentClient) | 本地持久化,cosine 距离空间 |
| Embedding | 本地模型优先,内置 hash 兜底 | 无模型文件时走字符 n-gram 特征,保证零下载可用 |
| 模型接入 | OpenAI SDK | 兼容 Ollama 与任意 OpenAI 兼容接口,Provider 化配置 |
| 前端 | Vue 3 本地运行时 | vue.global.prod.js 进仓库,无构建步骤、零 CDN |
当前规模:Python 约 6800 行(含测试),前端 JavaScript 约 3500 行(不含 Vue 运行时),22 个内置工具,12 个 pytest 用例,changecode.md 里 28 篇已修复问题的复盘记录与 5 条待修项。
二、分层与目录结构
frontend/ Vue 3 本地运行时(无构建、零 CDN)
SilverWolf.py 路由层:聊天 / 会话 / 流式 / 提供商 / 设置
agent/runner.py Agent 多步循环(模型 → 工具 → 模型)
agent/tools/ 工具仓库,一个功能一个文件,放进目录自动注册
middleware/context.py 上下文裁剪、压缩、截断
middleware/token_usage.py Token 统计与估算
middleware/stream_utils.py 流式增量归一化
file_history_store.py SQLite 会话与消息
rag.py / vector_stores.py / knowledge_base.py RAG 链路
provider_config.py 提供商与 API Key 管理
file_attachments.py 附件解析
依赖方向固定为 SilverWolf.py → agent/ → middleware/,下层不允许反向 import。这条规则的由来:早期 middleware 里的模块为了拿一个配置项反过来 import 路由层,结果一次改动会波及整条链路,也导致单元测试没法单独跑。
新代码放哪的判断标准:
| 特征 | 归属 |
|---|---|
| 只有 Agent 模式会用到 | agent/ |
| 模型可以主动调用 | agent/tools/ |
| 普通对话与 Agent 对话都要过一遍 | middleware/ |
| 业务主体(数据库、RAG、附件、配置) | 根目录对应模块 |
前端为什么退掉了构建步骤
第一版是标准的 npm 工程,后来改成 .js 文件直接加载。取舍如下:
| npm 工程 | 本地运行时(当前) | |
|---|---|---|
| 用户前置依赖 | Node + npm install | 无 |
| 模板写法 | .vue 单文件组件 |
模板字符串 |
| 类型检查 / 热更新 | 有 | 无 |
| 静态检查手段 | vue-tsc / eslint |
node --check + 浏览器模板编译验证 |
这个项目的目标用户是在自己电脑上跑助手的人,减少一个前置依赖的收益大于构建体验的损失。代价用两条纪律补:改前端必须过 node --check,以及必须用真实浏览器实际点击验证;静态资源 URL 全部带 ?v=版本号,避免浏览器拿着旧缓存跑新后端。
三、Agent 主循环
循环的基本形态是标准的 ReAct:调用带工具定义的模型 → 解析工具调用 → 执行工具 →把结果作为 role=tool 消息喂回上下文 → 继续下一轮,直到模型不再请求工具或达到轮数上限。真正需要处理的是边界条件。下面是循环骨架(agent/runner.py):
python
run_ctx = run_state.new_run() # 本轮工具状态:搜索/抓页计数与缓存
token_kwargs = {"max_tokens": reply_max_tokens} if reply_max_tokens else {}
answered = False
steps_total = max(1, int(max_steps or DEFAULT_MAX_STEPS))
for step in range(steps_total):
run_state.bind(run_ctx) # 每个 step 重新绑定(原因见第六节)
tracker.next_step()
content_parts, reasoning_parts, tool_buffer = [], [], {}
stream = client.chat.completions.create(
model=model, messages=messages, tools=tools_schema or None,
stream=True, stream_options={"include_usage": True}, **token_kwargs,
)
for chunk in stream:
content, reasoning = extract_stream_delta(chunk)
if reasoning:
yield {"type": "reasoning", "text": reasoning}
if content:
content_parts.append(content)
yield {"type": "content", "text": content}
choices = getattr(chunk, "choices", None)
if choices:
delta = getattr(choices[0], "delta", None)
calls = getattr(delta, "tool_calls", None) if delta is not None else None
if calls:
_merge_tool_call_fragments(tool_buffer, calls)
if getattr(chunk, "usage", None):
usage_obj = chunk.usage
text = "".join(content_parts)
reasoning_text = "".join(reasoning_parts)
if not tracker.add_provider_usage(usage_obj):
tracker.add_estimate(prompt_text, text + reasoning_text) # 服务商不给 usage 时退回估算
if not tool_buffer: # 没有工具调用 → 这就是最终回答
answered = True
break
...
工具调用是分片到达的
流式模式下,函数的 name 和 arguments 会分成多个 chunk 到达,例如 arguments 可能是 '{"ci'、'ty":"北'、'京"}'。必须按 index 累积,等这一轮结束后再整体解析:
python
def _merge_tool_call_fragments(buffer: Dict[int, Dict[str, Any]], deltas: List[Any]) -> None:
for d in deltas or []:
idx = getattr(d, "index", 0) or 0
slot = buffer.setdefault(idx, {"id": "", "name": "", "arguments": ""})
if getattr(d, "id", None):
slot["id"] = d.id
fn = getattr(d, "function", None)
if fn is not None:
if getattr(fn, "name", None):
slot["name"] = fn.name
if getattr(fn, "arguments", None):
slot["arguments"] += fn.arguments # 字符串拼接,不是覆盖
一次响应可能包含多个并行工具调用(index 不同),所以按 index 建槽位,执行时按 index 排序保证顺序稳定。
两种失败路径
服务商不支持工具或流式用量 。有些 OpenAI 兼容服务商不接受 tools 参数,或者不接受 stream_options。处理方式是逐级降级:先去 stream_options,再去 tools,最后按普通对话走:
python
except (BadRequestError, APIError) as e:
msg = str(e).lower()
if "max_tokens" in msg or "max output" in msg or ("maximum" in msg and "token" in msg):
stream = client.chat.completions.create(
model=model, messages=messages, tools=tools_schema or None,
stream=True, stream_options={"include_usage": True}) # 去掉 max_tokens
elif "tool" in msg or "function" in msg:
tools_unsupported = True
yield {"type": "warning", "text": "当前模型不支持工具调用,本次以普通对话模式回答"}
stream = client.chat.completions.create(model=model, messages=messages, stream=True)
else:
stream = client.chat.completions.create(
model=model, messages=messages, tools=tools_schema or None,
stream=True, **token_kwargs)
轮数或预算用尽但模型还想调工具。这时必须补一次"不带工具的收尾调用",否则用户拿到的最后一句往往只是模型的下一步计划:
python
if not answered:
yield {"type": "warning", "text": "工具轮数用尽,先把已拿到的资料整理成结论"}
tracker.next_step()
messages.append({
"role": "system",
"content": ("(系统)工具调用次数已用尽,现在禁止再调用任何工具。"
"请直接回答用户最初的问题:给出明确结论与关键数据,说明信息来自哪几个来源;"
"确实没查到的一部分就直说没查到。"),
})
final_stream = client.chat.completions.create(
model=model, messages=messages, stream=True,
stream_options={"include_usage": True}, **token_kwargs)
for chunk in final_stream:
content, _ = extract_stream_delta(chunk)
if content:
yield {"type": "content", "text": content}
单轮还有一个累计 token 硬闸:DEFAULT_MAX_TOTAL_TOKENS = 200_000。
每轮结束后检查 tracker.summary()["total_tokens"],超了就停止工具调用、走上面的收尾路径。设这一道的原因是 max_steps 只能限制轮数,限制不了"一步里发五个并行工具调用"带来的上下文膨胀。
四、工具系统
早期实现把所有工具放在一个 460 行的文件里:函数实现、JSON Schema、注册表、执行分发。加一个工具要改三处,其中"忘记登记进注册表"这一项不会报错,只会让模型看不到这个工具。现在改成一个功能一个文件,注册表扫描目录自动发现:
python
BUILTIN_TOOLS: Dict[str, Dict[str, Any]] = {}
def _discover() -> None:
package_dir = Path(__file__).resolve().parent
for mod_info in sorted(pkgutil.iter_modules([str(package_dir)]), key=lambda m: m.name):
if mod_info.name.startswith("_"): # 下划线开头 = 内部零件,不注册
continue
try:
module = importlib.import_module(f"{__name__}.{mod_info.name}")
except Exception as e: # 单个模块坏掉不拖垮整个 Agent
logger.exception("工具模块 %s 导入失败: %s", mod_info.name, e)
continue
for name, spec in (getattr(module, "TOOLS", {}) or {}).items():
if "handler" not in spec:
logger.warning("工具 %s 的定义不完整(缺少 handler),已跳过", name)
continue
BUILTIN_TOOLS[name] = spec
logger.info("已注册内置工具 %d 个:%s", len(BUILTIN_TOOLS), "、".join(BUILTIN_TOOLS))
每个工具文件只需要导出 TOOLS:
python
# agent/tools/datetime_tool.py
def _tool_get_current_time(args: Dict[str, Any]):
"""工具入口:无参数,返回 (给模型看的文本, 前端展示的 meta)。"""
info = get_current_time()
return info["text"], {"ok": True, "local_time": info["local_time"],
"weekday": info["weekday"], "timestamp": info["timestamp"]}
TOOLS = {
"get_current_time": {
"description": "获取当前的日期与时间(含星期与时区)......",
"parameters": {"type": "object", "properties": {}},
"handler": _tool_get_current_time,
},
}
执行侧统一做三件事:查表、兜异常、识别权限标记。
python
def execute_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
args = args or {}
spec = BUILTIN_TOOLS.get(name)
if spec:
try:
content, meta = spec["handler"](args)
return {"ok": bool(meta.get("ok", True)), "content": content, "meta": meta}
except Exception as e:
logger.exception("内置工具执行失败: %s", name)
return {"ok": False, "content": f"工具 {name} 执行异常:{str(e)[:200]}",
"meta": {"ok": False, "tool": name}}
custom = next((t for t in _load_custom_tools() if t.get("name") == name), None)
if custom:
content, meta = _run_custom_http_tool(custom, args)
return {"ok": bool(meta.get("ok", True)), "content": content, "meta": meta}
return {"ok": False, "content": f"没有名为 {name} 的工具。", "meta": {"ok": False, "tool": name}}
工具抛异常会被兜住并转成一条可读的工具结果,循环继续,模型能看到"这个工具失败了"并换别的方式。权限标记写在定义里,由循环统一检查:
python
"fs_write": {
"description": "在工作区里写入或追加一个文本文件......",
"approval_required": True, # 写盘属于高危操作,执行前需要用户允许
"handler": _tool_fs_write,
}
当前 22 个工具按功能分布:
| 分类 | 工具 |
|---|---|
| 联网 | web_search、fetch_page |
| 知识库 | rag_search、rag_list_files、rag_write_file、rag_delete_file、rag_list_knowledge_bases、rag_import_url |
| 长期记忆 | remember、recall、forget |
| 工作区 | fs_list、fs_read、fs_write、fs_delete |
| 任务规划 | update_plan、show_plan |
| 自我维护 | tools_list、tools_upsert、tools_delete |
| 基础能力 | get_current_time、calculate |
自定义工具走另一套:由 Agent 自己通过 tools_upsert 写成声明式 HTTP 工具,存 data/tools.json,执行时只做"参数替换进 URL 模板 + 发请求",不执行任意代码。
五、流式协议与"过程话"问题
POST /api/chat/stream 用 SSE 推送,每行格式是 data: {json}\n\n。事件类型:
| 事件 | 载荷 | 用途 |
|---|---|---|
content |
{content, session_id} |
正文增量 |
reasoning |
{reasoning} |
模型思考过程(推理模型) |
tool_call |
{tool_call:{name,args}} |
开始调用工具 |
tool_result |
{tool_result:{name,ok,summary}} |
工具执行结果 |
plan |
{plan:[{id,text,status}]} |
任务清单更新 |
tool_approval |
{tool_approval:{request_id,name,args}} |
高危操作等待用户允许 |
demote |
{demote:"<文本>"} |
把已经流出的过程话撤回 |
usage |
{usage:{prompt_tokens,...}} |
本轮 Token 用量 |
| 结尾 | {session_id,rag_used,provider_id,model,...} + [DONE] |
元信息与结束标志 |
demote:为什么要"先发再撤回"
工具调用型模型在调用工具之前,通常先输出一句打算,例如:
搜索结果全是导航站,没用。换几个精确的关键词。
这句话来自 content 字段,而循环把每一步的 content 都累积成了最终回答。当轮数用尽时流程直接结束,这句话就成了用户看到的"回答"。实测那次故障的完整回答是 71 个字符:
搜索结果全是导航站,没用。换几个精确的关键词。Bing爬虫给的都是导航/百科垃圾。
换Tavily试试。Tavily 有料了。挖几个页面看细节。
那一轮实际消耗 17232 Token(4 步),没有任何结论。修法分两部分:
- 带工具调用的那一步,把它输出的正文标记为"过程",从回答里撤回;
- 轮数/预算用尽时,强制补一次不带工具的收尾调用(见第三节)。
撤回用事件而不是"先缓存不发"的原因:如果为了判断这句话是不是最终回答而缓存整段文本,用户会先看到十几秒空白,流式的打字效果就没了。所以照常流出,事后发一个 demote:
python
# agent/runner.py
if text.strip():
yield {"type": "demote", "text": text}
javascript
// frontend/src/stores.js ------ 收到 demote:从回答里摘出来,挪进思考过程
if (parsed.demote) {
const dropped = parsed.demote;
if (fullText.endsWith(dropped)) {
fullText = fullText.slice(0, fullText.length - dropped.length);
} else {
fullText = fullText.split(dropped).join('');
}
botMsg.content = fullText;
const trimmed = dropped.trim();
if (trimmed) {
fullReasoning += (fullReasoning ? '\n' : '') + trimmed;
botMsg.reasoning = fullReasoning;
}
}
三处都要改:SSE 转发层(SilverWolf.py)、前端解析(stores.js)、非流式合并(run_agent)。漏掉非流式那一侧,就会出现"页面上正常、换调用方式少一半内容"。
改动前后同一条问题的对比:
| 修复前 | 修复后 | |
|---|---|---|
| 回答长度 | 71 字符 | 3269 字符 |
| 内容 | 过程话 | 型号、价格、来源 |
| 过程话位置 | 回答气泡 | 思考过程面板 |
六、上下文与 token 成本
成本模型
同一模型、同一天的实测数据:
| 场景 | 输入 Token |
|---|---|
| 单轮问答(调用一次时间工具) | 4339 |
| 3 步联网问答 | 14478 |
| 4 步联网问答 | 15145 |
原因是 LLM 接口无状态:Agent 每走一步都要把"到目前为止的全部内容"重新发送一次。成本近似等于 上下文大小 × 轮数,其中被放大的部分有四块:
- 每步重发(4 步 = 同一份上下文付 4 次);
- 输入占账单主体(15000 Token 里绝大部分是 Prompt);
- 工具返回内容体积大(一次搜索 5 条结果加一篇正文,几千字符,且之后每步都跟着重发);
- 历史只按条数限制(最早实现是
MAX_HISTORY = 20,20 条长消息可以是两万 Token)。
Token 估算
部分服务商(含 Ollama)不返回 usage,所以需要估算。中日韩字符按 1 字 ≈ 1 token,其余按 4 字符 ≈ 1 token,每条消息额外加 4 个 token 作为角色与结构开销,工具调用的 arguments 同样计入输入:
python
# middleware/token_usage.py
_CJK_RE = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
def estimate_tokens(text: str) -> int:
if not text:
return 0
cjk = len(_CJK_RE.findall(text))
others = max(len(text) - cjk, 0)
return max(int(cjk + others / 4), 1)
def estimate_messages_tokens(messages: list) -> int:
total = 0
for m in messages or []:
content = m.get("content") if isinstance(m, dict) else ""
total += estimate_tokens(content if isinstance(content, str) else "") + 4
if isinstance(m, dict) and m.get("tool_calls"):
for call in m["tool_calls"]:
fn = (call or {}).get("function", {})
total += estimate_tokens(str(fn.get("arguments", ""))) + 4
return total
UsageTracker 优先累加服务商返回的真实值,拿不到时退回估算,最终结果里带一个 source 字段标明这次显示的是实测还是估算值。
裁剪算法
预算定义在整个请求上(system 提示 + RAG 片段 + 历史 + 当前提问),这样界面上显示的"200K 窗口"和实际发送量一致。裁剪从最旧的开始丢,但有两类内容宁可超出预算也要保留:
python
# middleware/context.py
def trim_messages(messages, budget_tokens=..., keep_head=2,
min_keep_turns=DEFAULT_MIN_KEEP_TURNS, unlimited=False):
if not messages:
return [], []
if unlimited:
return list(messages), [] # 用户显式选择"无限轮数"
head = messages[:keep_head] # system 提示 + 助手开场
tail = messages[keep_head:]
last = tail[-1] # 当前提问,必留
history = tail[:-1]
anchors_cost = _messages_cost(head) + _messages_cost([last])
min_keep_msgs = max(0, int(min_keep_turns) * 2) # 一轮 = 一问一答
kept_history, used = [], 0
for msg in reversed(history):
cost = _messages_cost([msg])
if anchors_cost + used + cost > budget_tokens and len(kept_history) >= min_keep_msgs:
break # 超出预算就停,但先保证保底轮数
kept_history.append(msg)
used += cost
kept_history.reverse()
dropped = history[: len(history) - len(kept_history)]
return head + kept_history + [last], dropped
两个参数的作用不同:keep_head 保护固定锚点,min_keep_turns 保证最近若干轮一定在。预算可以调小到 4K,但保底轮数让模型不至于彻底断片。
被裁掉的对话压成摘要
裁掉的内容不会直接丢弃:交给模型压成一段短摘要存进会话,下次作为 system 消息带回。
python
prompt = (
"请把下面这段对话历史压缩成不超过 200 字的中文要点,只保留:用户的目标、"
"已确认的事实/结论、未完成的事项。不要寒暄,不要复述原文。\n"
+ (f"\n【已有摘要,可合并】\n{previous_summary}\n" if previous_summary else "")
+ f"\n【待压缩的对话】\n{transcript}"
)
resp = client.chat.completions.create(model=provider.get("model"),
messages=[{"role": "user", "content": prompt}],
max_tokens=300, temperature=0.2)
几个限值都写成了常量:单条历史最多取 1200 字符、转录总长 6000 字符、压缩请求 max_tokens=300、摘要最终截到 600 字符。
摘要与旧摘要合并(滚动摘要),存在 sessions.summary 字段;压缩失败只丢历史,不影响这一轮对话。
实测效果:一段 35076 token 的长历史裁剪后剩 6676(其中 996 是必留的锚点),历史部分减少约 90%。
「无限」的表示方法
界面上的「无限轮数」「无限长度」开关,实现上没有使用大数字:
| 开关 | 实现 |
|---|---|
| 无限轮数 | trim_messages 直接返回全部消息;预算只作展示 |
| 无限长度 | 调用模型时不发送 max_tokens 参数 |
原因是多数服务商对 max_tokens 有硬上限,超了直接返回 400;显式传 null 也有服务商视为非法值。用参数缺席表达「没有额外限制」最稳:
python
token_kwargs = {"max_tokens": reply_max_tokens} if reply_max_tokens else {}
stream = client.chat.completions.create(model=model, messages=messages, stream=True, **token_kwargs)
七、联网搜索
后端与降级
| 后端 | 依赖 | 特点 |
|---|---|---|
| Tavily | API Key | 结果质量最好,按调用量计费 |
| Bing 结果页解析 | 无 | 免 Key,靠 HTML 结构与浏览器 UA |
engine="auto" 时按设置决定:开关打开且配了 Key 就走 Tavily,失败自动降级到爬虫。
爬虫实现里有两个必要的处理:伪装常见浏览器 UA(部分搜索页对 Python 默认 UA 返回空结果),以及限速(两次请求至少间隔 1.5 秒加 0.3-0.8 秒随机抖动):
python
_SCRAPE_MIN_INTERVAL = 1.5
_LAST_SCRAPE_TS = 0.0
def throttle() -> None:
global _LAST_SCRAPE_TS
now = time.time()
wait = _SCRAPE_MIN_INTERVAL - (now - _LAST_SCRAPE_TS)
if wait > 0:
time.sleep(wait)
time.sleep(random.uniform(0.3, 0.8))
_LAST_SCRAPE_TS = time.time()
百度在实测中不可用:无 Cookie 请求会返回"安全验证"页(HTTP 200,正文约 1.4 KB),解析不到结果,所以从可用后端里移除了。
本轮记忆与限流
模型没有"本轮记忆",会把同一个问题换种说法反复搜索。实测一轮出现过 10 次 web_search + 8 次 fetch_page,prompt token 因此涨到 50957。
解决方案是给每轮运行配一个状态盒子(agent/tools/_run_state.py),记录计数与缓存,重复查询直接复用结果:
python
run = run_state.state()
cache_key = run_state.normalize_query(query)
if run is not None:
cached = run["search_cache"].get(cache_key)
if cached:
return ("(这个查询你刚才已经搜过,下面是同一批结果,不要再重复搜同一个问题)\n"
+ cached["text"], cached["meta"])
if run["searches"] >= run["max_searches"]:
return (f"本轮搜索次数已达上限({run['max_searches']} 次)。"
"别再调用搜索了 ------ 用已经拿到的资料直接把结论写出来;"
"确实查不到的部分,就明确说这部分没查到,并给出你建议的下一步。",
{"ok": True, "query": query, "sources": [], "engine": "limit-reached"})
查询归一化只做去空格、去标点、转小写,不做分词与同义词替换(过于激进的归一化会拦掉"确实需要重新搜"的情况):
python
def normalize_query(text: str) -> str:
text = (text or "").strip().lower()
for ch in " \t\u3000,,。.、;;::!!??\"'""''()()[]【】<>《》-_/\\|+*#~`":
text = text.replace(ch, "")
return text
限流返回的是提示而非错误。返回异常的话模型会当成故障反复重试;给它一句"别再搜了,用已有资料作答",它会转向收尾。上限按"调用次数"计数,因为 max_steps 限制的是轮数,管不住一步之内的并行调用。
状态盒子与线程 :状态本身是一个普通 dict,存在生成器帧里;每步开头通过 contextvars 绑定,供同一步内的工具读取。之所以不"在函数开头绑定一次",是因为 FastAPI 处理同步生成器时把它交给线程池逐步迭代,线程池调用只复制上下文,在线程里 set() 的结果不会回传调用方,第二步就会读不到状态。
结果过滤
修复前一次搜索的前 7 条里有 5 条是工具导航站与百科词条:
1. AI 工具集官网 | 1000+ AI 工具集合,国内外 AI 工具集导航大全 https://ai-bot.cn/
2. 免费 AI 工具 - 在线使用免费 AI 工具大全 | AIGC工具导航 https://www.aigc.cn/...
3. AskGo - 免费体验GPT,Claude,Gemini,Grok,DeepSeek https://askgo.ai/
4. OpenAI _百度百科 https://baike.baidu.com/...
搜索引擎按点击率排序,中文 SEO 农场专门针对"2026 最新"这类时间词。因此在返回结果前加一层域名分级与去重:
python
def _rank_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""去重 + 过滤农场站 + 官方来源置顶 + 同域名最多 2 条。"""
seen_urls, per_domain, kept = set(), {}, []
for item in results:
url = str(item.get("url") or "").strip()
if not url or url in seen_urls:
continue
seen_urls.add(url)
domain = urllib.parse.urlsplit(url).netloc.lower()
if per_domain.get(domain, 0) >= 2:
continue
per_domain[domain] = per_domain.get(domain, 0) + 1
kept.append(item)
filtered = [r for r in kept if _domain_score(r.get("url", "")) > -100]
if filtered: # 全部被过滤时保留原结果,不返回空列表
kept = filtered
return sorted(kept, key=lambda r: -_domain_score(r.get("url", "")))
def _domain_score(url: str) -> int:
u = (url or "").lower()
if any(h in u for h in _NAV_FARM_HINTS): # 导航站 / 工具农场
return -100
if any(h in u for h in _LOW_VALUE_HINTS): # 百科 / 问答
return -5
if any(h in u for h in _OFFICIAL_HINTS): # 厂商官方域名
return 5
if any(h in u for h in _NEWS_HINTS): # 科技与财经媒体
return 3
return 0
结果条数同时收敛:默认 3 条、上限 5 条(此前默认 5、上限 10)。改完之后的来源列表:
https://openai.com/index/gpt-5-6
https://api-docs.deepseek.com/zh-cn/news/news260910
https://api-docs.deepseek.com/zh-cn/quick_start/pricing
https://www.ithome.com/1/000/719.htm
http://finance.people.com.cn/BIG5/n1/2026/0616/c1004-40741332.html
同一问法的整体对比:
| 收敛前 | 收敛后 | |
|---|---|---|
| 工具调用 | 10 次搜索 + 8 次抓页 | 4 次 + 3 次 |
| 单次结果 | 8~10 条 | ≤5 条 |
| prompt token | 50957 | 28812 |
八、RAG:分块与离线 Embedding
分块
按行拼装,尽量保持段落完整;单行超过块大小时才按字符硬切,切的时候保留重叠,避免把一句关键的话从中间断开:
python
# knowledge_base.py,CHUNK_SIZE = 500,CHUNK_OVERLAP = 50
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
if not text.strip():
return []
chunks, current_chunk = [], ""
for line in text.split("\n"):
if len(current_chunk) + len(line) + 1 <= chunk_size:
current_chunk = (current_chunk + "\n" + line).strip()
else:
if current_chunk:
chunks.append(current_chunk)
if len(line) > chunk_size: # 单行超长 → 按字符切
for i in range(0, len(line), chunk_size - overlap):
chunk = line[i:i + chunk_size]
if chunk:
chunks.append(chunk)
current_chunk = ""
else:
current_chunk = line
if current_chunk:
chunks.append(current_chunk)
return [c.strip() for c in chunks if c.strip()]
检索侧参数:RAG_TOP_K = 5(返回 5 个片段)、RAG_SIMILARITY_THRESHOLD = 0.5(相似度阈值)。
离线 Embedding 兜底
项目的承诺之一是离线可用,而常见的 Embedding 方案需要下载几百 MB 的模型文件。
所以做了两级:优先加载本地 sentence-transformers 模型,找不到时使用内置的字符 n-gram 哈希特征,不下载任何东西:
python
# vector_stores.py
class LocalHashEmbeddingFunction:
"""
基于字符 n-gram 的确定性 Hash 特征向量。
特征:unigram(权重 1) + bigram(权重 2) + trigram(权重 3),计数累加;
维度固定 1024(与 bge-large-zh 对齐),向量做 L2 归一化。
"""
def _hash_index(self, prefix: str, token: str) -> int:
digest = hashlib.md5((prefix + token).encode("utf-8")).digest()
return int.from_bytes(digest[:4], "big") % self.dimension
def _embed_one(self, text: str) -> List[float]:
vec = [0.0] * self.dimension
s = (text or "").lower()
for ch in s:
vec[self._hash_index("1:", ch)] += 1.0
for i in range(len(s) - 1):
vec[self._hash_index("2:", s[i:i + 2])] += 2.0
for i in range(len(s) - 2):
vec[self._hash_index("3:", s[i:i + 3])] += 3.0
norm = math.sqrt(sum(v * v for v in vec))
return [v / norm for v in vec] if norm > 0 else vec
前缀("1:"、"2:"、"3:")用来隔离不同阶的特征,避免 bigram 与 trigram 撞到同一个桶。
这套方案的检索质量低于神经模型,但它是"离线可用"这条承诺的兜底路径,启动日志里会明确打印当前用的是哪一级:
[WARNING] vector_stores: Embedding 后端: local-hash(内置离线,零下载)
(未找到本地 Transformer 模型,使用内置离线 Embedding)
Collection 命名与元数据校验
Chroma 的 collection 名有合法字符限制,且 Embedding 模型或维度变化会让已有向量失效。
处理方式是把 kb 的 slug 转成稳定 ASCII 名称,并把 Embedding 元数据写进 collection:
python
def _make_collection_name(kb_slug: str) -> str:
"""仅允许 ASCII [a-zA-Z0-9._-],不符合要求时替换为下划线。"""
safe_chars = []
for ch in kb_slug:
if ch.isascii() and (ch.isalnum() or ch in "._-"):
safe_chars.append(ch)
else:
safe_chars.append("_")
return f"kb_{''.join(safe_chars)}"
def _validate_collection_metadata(collection) -> None:
meta = collection.metadata or {}
stored_model = meta.get("embedding_model", "")
stored_dim = meta.get("embedding_dimension", 0)
if stored_model and stored_model != EMBEDDING_MODEL_NAME:
raise RuntimeError(f"Embedding 模型不匹配:collection 使用 {stored_model},"
f"当前配置为 {EMBEDDING_MODEL_NAME}。请重建知识库。")
if stored_dim and stored_dim != EMBEDDING_DIMENSION:
raise RuntimeError(f"Embedding 维度不匹配:collection 为 {stored_dim} 维,"
f"当前配置为 {EMBEDDING_DIMENSION} 维。请重建知识库。")
维度不一致时直接报错而不是静默新建 collection。静默处理会导致"检索看起来在工作,但结果一直是乱的"这类难查的问题。
九、数据持久化
三张表:会话、消息、附件引用。消息表用 meta 字段存 JSON,把 Token 用量、工具调用、搜索来源、RAG 命中一起落库,刷新页面后气泡底部的统计仍然完整。
python
def init_db() -> None:
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New Chat',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
""")
# SQLite 不支持 IF NOT EXISTS 加列,用 try/except 做幂等迁移
try:
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
except sqlite3.OperationalError:
pass
附件的策略是把文件落在 data/uploads/<日期>/,数据库只存引用(路径、名字、大小、SHA256),删除会话时按引用清文件:
python
conn.execute("""
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
path TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_session_path "
"ON attachments(session_id, path)")
构建上下文时的消息顺序
python
def build_messages(session_id, user_message, system_prompt, assistant_greeting, max_history=MAX_HISTORY):
messages = [
{"role": "system", "content": system_prompt},
{"role": "assistant", "content": assistant_greeting},
]
rows = conn.execute("SELECT role, content FROM messages WHERE session_id = ? "
"ORDER BY id DESC LIMIT ?", (session_id, max_history)).fetchall()
messages += [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
messages.append({"role": "user", "content": user_message})
return messages
顺序是 system 提示、助手开场、历史、当前提问。RAG 片段与长期记忆通过 insert(2, ...) 插在开场之后,这个位置选择带出一个已知问题,写在最后一节。
十、权限与安全边界
工作区沙箱
模型能读写的是 data/workspace/ 一个目录,所有文件工具的入口都先过同一个解析函数:
python
# agent/tools/workspace.py
def _safe_resolve(rel_path: str) -> Path:
raw = (rel_path or "").strip().replace("\\", "/")
if not raw or raw in (".", "./"):
return WORKSPACE_DIR.resolve()
if raw.startswith("/") or ":" in raw.split("/")[0]:
raise ValueError("不允许使用绝对路径,请用相对工作区的路径")
target = (WORKSPACE_DIR / raw).resolve(strict=False)
if not target.is_relative_to(WORKSPACE_DIR.resolve()):
raise ValueError("路径逃逸出工作区,被拦下了(不许用 .. 或符号链接出界)")
return target
resolve(strict=False) 会展开 .. 与符号链接,随后判断展开结果是否仍在根目录内。
这一条同时覆盖相对路径穿越、绝对路径、Windows 盘符和软链接出界。
删除只允许单文件或空目录,非空目录直接拒绝,避免误操作清空工作区。
高危操作审批
写文件、删文件、增删改自定义工具这四类操作,执行前弹窗等待用户确认。实现是一次基于 SSE 的请求-响应:服务端发事件并等待,前端弹窗把结果 POST 回来。
python
# agent/runner.py
APPROVAL_REGISTRY: Dict[str, Dict[str, Any]] = {}
APPROVAL_TIMEOUT = 60.0
spec = agent_tools.BUILTIN_TOOLS.get(c["name"]) or {}
if spec.get("approval_required"):
request_id = f"apv_{uuid.uuid4().hex[:12]}"
APPROVAL_REGISTRY[request_id] = {"approved": False, "event": threading.Event()}
yield {"type": "tool_approval", "request_id": request_id,
"name": c["name"], "args": c["args"]}
waited = APPROVAL_REGISTRY[request_id]["event"].wait(APPROVAL_TIMEOUT)
approved = APPROVAL_REGISTRY.get(request_id, {}).get("approved", False) if waited else False
APPROVAL_REGISTRY.pop(request_id, None)
if not approved:
reason = "用户拒绝了这次操作" if waited else f"等待批准超时({int(APPROVAL_TIMEOUT)} 秒),已自动拒绝"
tools_used.append({"name": c["name"], "args": c["args"], "ok": False, "denied": True})
yield {"type": "tool_result", "name": c["name"], "ok": False, "summary": reason,
"meta": {"denied": True, "tool": c["name"]}}
messages.append({
"role": "tool",
"tool_call_id": c["id"],
"content": f"工具 {c['name']} 被跳过:{reason}。"
"向用户说明情况,改用其它方式或等用户同意后再试。",
})
continue
回调接口只做一件事:写标记、置事件。
python
@router.post("/api/agent/approval")
async def api_agent_approval(req: ApprovalRequest):
entry = agent_runner.APPROVAL_REGISTRY.get(req.request_id)
if not entry:
return {"ok": False, "error": "审批请求不存在或已超时"}
entry["approved"] = req.approve
entry["event"].set()
return {"ok": True}
被拒绝时返回的是"这一步被跳过"的普通工具结果,模型可以改方案,整轮对话不会因为一次拒绝而中断。
计算器:AST 白名单
模型的算术不可靠,需要给它计算能力,但 eval() 等于交出整台机器。实现方式是解析成 AST 后逐节点白名单放行:
python
# agent/tools/calculator.py
_BIN_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow}
_SAFE_FUNCS = {"abs": abs, "round": round, "min": min, "max": max, "pow": pow, "sum": sum,
"sqrt": math.sqrt, "floor": math.floor, "ceil": math.ceil,
"log": math.log, "log10": math.log10, "log2": math.log2,
"sin": math.sin, "cos": math.cos, "tan": math.tan,
"degrees": math.degrees, "radians": math.radians,
"factorial": math.factorial, "gcd": math.gcd}
_SAFE_NAMES = {"pi": math.pi, "e": math.e, "tau": math.tau}
def _eval_node(node: ast.AST):
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float, complex, bool)):
return node.value
raise ValueError(f"不支持的常量类型:{type(node.value).__name__}")
if isinstance(node, ast.Name):
if node.id in _SAFE_NAMES:
return _SAFE_NAMES[node.id]
raise ValueError(f"不允许使用变量:{node.id}")
if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
return _BIN_OPS[type(node.op)](_eval_node(node.left), _eval_node(node.right))
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id not in _SAFE_FUNCS:
raise ValueError(f"不允许调用的函数:{node.func.id}")
if node.keywords:
raise ValueError("不支持关键字参数")
return _SAFE_FUNCS[node.func.id](*[_eval_node(a) for a in node.args])
raise ValueError(f"不支持的表达式形式:{type(node).__name__}(只允许数值运算)")
属性访问、下标、赋值、循环、导入都会落到最后一个分支被拒绝。
留痕
所有自我修改动作(写知识库、写工作区、增删改自定义工具)都经过同一个记录函数,写进 data/agent_changes.log(JSON Lines),设置页可以查看最近的记录:
python
def log_change(action: str, detail: Dict[str, Any]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
line = json.dumps({"ts": int(time.time()), "action": action, "detail": detail},
ensure_ascii=False)
with open(CHANGES_LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
十一、跨会话长期记忆
存储在 data/memory.json,条目结构包含 id、内容、标签、时间戳、来源。检索不使用向量库,用相邻双字 bigram 的重叠比例打分:
python
def _content_terms(text: str) -> set:
"""英文/数字按单词切,中文按相邻双字切 bigram。"""
text = (text or "").lower()
terms = set(re.findall(r"[a-z0-9]+", text))
for han in re.findall(r"[\u4e00-\u9fff]+", text):
terms.update(han[i:i + 2] for i in range(max(0, len(han) - 1)))
return terms
def _match_score(query: str, content: str) -> float:
q_terms = _content_terms(query)
if not q_terms:
return 0.0
content_lower = (content or "").lower()
hits = [t for t in q_terms if t in content_lower]
if not hits:
return 0.0
base = len(hits) / len(q_terms) # 命中占比
bonus = min(1.0, max(len(t) for t in hits) / 8.0) * 0.15 # 更长的命中词给一点加权
return base + bonus
中文没有空格分隔,单字命中噪声太大,整句匹配又太严,双字是成本最低的折中。单条记忆长度限制 2000 字符,防模型把整段对话塞进去;召回默认最多 5 条,一次注入过多的记忆同样是浪费上下文。
选这个方案的理由是数据规模:记忆条目通常是一两句话,bigram 重叠在这个粒度上足够;语义检索由 RAG 那条链路承担,没必要为轻量功能再加载一个模型进内存。
十二、前端实现
无构建带来的两个具体问题
模板是字符串 。组件写在 app.js 里,模板用反引号包裹,改一处模板不需要重新构建,但也没有编译期检查。补的手段是在 Node 里用仓库自带的 Vue 运行时编译全部模板,做一次静态校验。
响应式只在代理上生效。流式输出时如果直接修改 push 之前的原始对象,界面不会更新;必须拿到数组里的响应式代理再改:
javascript
// 正确:从响应式数组里取最后一条(拿到的是代理)
const botMsg = this.messages[this.messages.length - 1];
botMsg.content += chunk;
这条是项目早期"流式输出不显示、刷新后才出现"的根因之一。
停止生成
前端持有当前请求的 AbortController,发送按钮在生成期间切换为停止键:
javascript
this.abortController = new AbortController();
const response = await fetch('/api/chat/stream', {
method: 'POST', body: JSON.stringify(payload),
signal: this.abortController.signal,
});
...
stopGenerating() {
if (this.abortController) {
try { this.abortController.abort(); } catch (e) { /* ignore */ }
}
this.sending = false;
const last = this.messages[this.messages.length - 1];
if (last && last.role === 'bot' && last.streaming) {
last.streaming = false;
if (!last.content) last.content = '(已停止生成)';
last.stopped = true;
}
}
中断在服务端的表现(生成器被关闭)带出一个已知问题,见最后一节。
图标系统
界面不用 emoji 当图标,改用自绘的 16 × 16 像素风 SVG,统一由 icons.js 提供组件与 favicon 生成函数。好处是颜色跟随主题变量、渲染体积小、不会因为系统 emoji 字体不同而走形。
十三、测试
三层,成本从低到高。
静态检查
bat
.venv\Scripts\python.exe -m py_compile SilverWolf.py agent\runner.py
node --check frontend\src\app.js
node --check frontend\src\stores.js
py_compile 只要几十毫秒,但能拦住全部语法与缩进错误。曾经有一次用补丁工具改代码,匹配位置出错,一段调用被复制到文件末尾,拿到 IndentationError: unexpected indent;
错误被推迟到启动服务才暴露,中间还白跑了一轮测试。之后把"改完立刻编译"变成固定动作。
pytest 冒烟
tests/test_smoke.py 目前 12 个用例,覆盖容易在重构中坏掉的链路:
| 用例 | 覆盖内容 |
|---|---|
| 工具注册表 | 关键工具是否都被自动发现 |
| 权限标记 | 写/删类工具必须带 approval_required |
| 长期记忆 | remember → recall → forget 全链路,无关话题不被召回 |
| 工作区沙箱 | 路径穿越、绝对路径、盘符被拦;写→读→删正常 |
| 计算器 | 正常算术结果 + 恶意表达式全部被拒 |
| 任务计划 | 状态机与非法状态归一化 |
| 审批链路 | 用假 OpenAI 客户端驱动真实循环:允许则写盘,拒绝则不写盘 |
| SSE 归一化 | 空 choices、delta.content 为 list 的处理 |
审批用例的做法是给 runner 换一个假客户端,按剧本返回"要求调用工具"再"给出回答",这样不用真联网也能验证完整循环。假客户端用一个逐步给出帧的迭代器模拟流式响应:
python
class _FakeStream:
"""第 1 步返回一个 fs_write 的工具调用,第 2 步返回普通文本。"""
def __init__(self, step):
self.step = step
self.frames = list(self._frames())
def _frames(self):
if self.step == 1:
yield _chunk(_delta_fn("fs_write", '{"path": "ap_test.txt", "content": "hi"}', "c1"))
else:
yield _chunk(_delta_txt("写完了。"))
def __iter__(self):
return self
def __next__(self):
if self.ix < len(self.frames):
f = self.frames[self.ix]; self.ix += 1
return f
raise StopIteration
class _FakeCompletions:
def __init__(self, cli): self.cli = cli
def create(self, **kw): self.cli.calls += 1; return _FakeStream(self.cli.calls)
审批的响应由另一个线程在 0.3 秒后触发,模拟用户点按钮:
python
def _run_agent_with_approval(fake_client, approve, monkeypatch):
events, feed = [], {"rid": None}
def respond():
time.sleep(0.3)
entry = runner.APPROVAL_REGISTRY.get(feed["rid"])
entry["approved"] = approve
entry["event"].set()
t = threading.Thread(target=respond); t.start()
for ev in runner.run_agent_stream(provider, messages,
enabled_tools=["fs_write"], max_steps=2):
events.append(ev)
if ev["type"] == "tool_approval":
feed["rid"] = ev["request_id"]
t.join(timeout=5)
return events
def test_approval_allowed_writes_file(fake_client, clean_workspace, monkeypatch):
events = _run_agent_with_approval(fake_client, approve=True, monkeypatch=monkeypatch)
assert "tool_approval" in [e["type"] for e in events]
f = workspace.WORKSPACE_DIR / "ap_test.txt"
assert f.exists() and f.read_text(encoding="utf-8") == "hi"
assert not runner.APPROVAL_REGISTRY, "审批注册表未清理"
用例都带清理 fixture(fake_client / clean_workspace / clean_memory):
跑之前备份、跑之后恢复自己碰过的数据文件,不残留、不动用户数据。
真实浏览器
涉及交互的功能(设置弹窗、模型下拉、审批弹窗、附件上传)用真实 Chrome 验证,通过 CDP 驱动:
python
targets = httpx.get("http://127.0.0.1:9227/json/list").json()
page = next(t for t in targets if t["type"] == "page" and "127.0.0.1:8010" in t["url"])
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=30, suppress_origin=True)
def js(expr):
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "returnByValue": True}}))
...
js("document.querySelector('.settings-fab').click()")
两个具体问题:
- 新版 Chrome 会拒绝带
Origin头的 WebSocket 握手(403),需要在客户端suppress_origin=True,或在启动参数里加--remote-allow-origins; - 截图容易拿到黑图或只拍一角:需要先发
Page.enable,并确认 viewport 足够大。因此断言以"读 DOM 文本"为主,截图只用于给人看。
十四、已知问题
以下几项已经定位但尚未修复,细节与修复方案记录在仓库的 changecode.md 第 7 节:
- 注入的 RAG 片段与长期记忆会被裁剪最先丢掉 。它们被插在消息列表第 2、3 位,
而裁剪只保护前keep_head条,于是落在"可裁剪历史"里并且是第一批被丢弃的。
实测:18603 token 的上下文,预算压到 17900 时,最先丢掉的两条正是「长期记忆」与
「知识库检索结果」。修法是让裁剪把 system 角色当作锚点,或按开头连续的 system 消息计算keep_head。 - token 硬闸分支绕过了
demote,触发硬闸那一轮的过程话会留在回答气泡里。 - 「停止生成」之后这一轮不落库 。浏览器中断导致服务端生成器被关闭,
保存代码写在正常路径上,GeneratorExit也不会被except Exception捕获。 - 审批等待会占用线程池工作线程 ,最长 60 秒;另外审批范围不一致------
工作区写文件需要允许,知识库写入不需要。 - 工具层缺少资源上限 。计算器允许
2**100000000这类表达式,
fs_write也没有单文件大小限制。
十五、小结
把工具接上模型之后,工作量集中在几个不显眼的地方:
- 状态 。模型不记得自己刚才搜过什么,所以框架要提供本轮状态(计数、缓存),
并且要注意它存放的位置会不会被线程池或上下文切换吃掉。 - 输出的语义分层 。
content里既有结论也有过程,
框架必须把它们分开,并保证"轮数用尽"这类退出路径也能产出结论。 - 成本 。无状态接口让每一步都重发上下文,成本随上下文大小与轮数相乘增长,
限制条数、截断工具结果、裁剪历史、设置硬闸都属于同一件事。 - 权限。一旦能改文件,沙箱、审批、审计就从"加分项"变成"能不能给人用的前提"。
- 可回滚 。每一步一个提交、每个坑一篇记录,出问题时能定位到具体改动,
也让这套系统在被替换(换模型、换工具、换前端)时仍然可维护。
项目仓库为 S_Wolf(MIT)。角色「银狼」来自《崩坏:星穹铁道》,
仓库不含任何官方素材,人设只影响交互文案的措辞。