数据截至 (上游 commit ae57a2357745)
第 5 章 · LLM 后端与记忆
本章讲:同一套 agent 代码怎么同时支持本地 Ollama 和云端 Anthropic;以及对话历史怎么存、怎么(试图)压缩。
5.1 Provider:一张字典就是全部抽象
没有基类、没有插件注册、没有 ABC。构造函数里一个字典就是整个抽象层(sources/llm_provider.py:27-42):
self.available_providers = {
"ollama": self.ollama_fn,
"server": self.server_fn,
"openai": self.openai_fn,
"lm-studio": self.lm_studio_fn,
"huggingface": self.huggingface_fn,
"google": self.google_fn,
"deepseek": self.deepseek_fn,
"together": self.together_fn,
"dsk_deepseek": self.dsk_deepseek,
"openrouter": self.openrouter_fn,
"anthropic": self.anthropic_fn,
"minimax": self.minimax_fn,
"litellm": self.litellm_fn,
"test": self.test_fn
}
每个函数签名相同:(history, verbose) -> str。respond() 查表调用(sources/llm_provider.py:76-100)。
优点:加一个后端 = 加一个方法 + 加一行字典项,没有任何脚手架。
缺点:所有后端的实现挤在一个 557 行的文件里,Provider 类同时管配置、鉴权、Docker 网络和 14 种 API 调用。
本地 vs 云端的分界
self.unsafe_providers = ["openai", "deepseek", "dsk_deepseek", "together",
"google", "openrouter", "anthropic", "minimax"]
(sources/llm_provider.py:46)
命中这个名单且 is_local == False 时,构造函数会打印一句明确的警告并去取 API key(49-51):
“Warning: you are using an API provider. You data will be sent to the cloud.”
对一个「隐私优先」的项目来说,这行 print 是有立场的——它不阻止你,但一定让你知道。
注意 openai 同时出现在「本地」和「云端」两栏:is_local=True 时它指向本机的 OpenAI 兼容服务(llama.cpp、vLLM 等),is_local=False 才是真的 OpenAI。分支在 openai_fn 里(sources/llm_provider.py:225-235)。
三类调用形态
| 形态 | 后端 | 特点 |
|---|---|---|
| 官方 SDK | ollama、anthropic、together、litellm | 各用各的库 |
| OpenAI 兼容客户端 | openai、google、deepseek、openrouter、minimax | 都是 OpenAI(api_key=..., base_url=...),只换 base_url |
| 裸 HTTP | lm-studio、server | requests.post 手拼 payload |
第二类占了近一半——OpenAI 的 wire format 事实上成了通用协议,Google Gemini 也提供 .../v1beta/openai/ 兼容端点。
只有 Ollama 是流式的
stream = client.chat(model=self.model, messages=history, stream=True)
for chunk in stream:
if verbose:
print(chunk["message"]["content"], end="", flush=True)
thought += chunk["message"]["content"]
(sources/llm_provider.py:179-187)
但流式只用于终端回显,函数最终还是返回拼完的整串。上层 Agent.sync_llm_request() 要的是完整文本才能做代码块解析,所以流式没法向上传递。
Ollama 还有个贴心处理:报 404(模型没下载)时自动 client.pull(self.model) 然后递归重试一次(sources/llm_provider.py:193-196)。
Docker 网络的处理
后端在容器里、LLM 在宿主机上,是这个项目最常见的部署形态。处理办法是一个环境变量(get_internal_url,sources/llm_provider.py:69-74):
url = os.getenv("DOCKER_INTERNAL_URL")
if not url: # 跑在宿主机
return "http://localhost", False
return url, True
docker-compose.yml 里把它设成 http://host.docker.internal 并配了 extra_hosts: host.docker.internal:host-gateway。于是 ollama_fn / lm_studio_fn / openai_fn 里都是同一个模式:只保留配置里的端口号,主机名换成 internal URL(例如 sources/llm_provider.py:171-175)。
lm_studio_fn 还多做一步——只有当配置的主机名是 localhost/127.0.0.1 时才替换,指向别的机器时保持原样(sources/llm_provider.py:366-369)。
错误信息面向人写
respond() 的异常处理不是简单往上抛,而是翻译成人话(sources/llm_provider.py:84-99):
| 捕获 | 变成 |
|---|---|
KeyboardInterrupt | 返回 "Operation interrupted by user. REQUEST_EXIT" |
AttributeError | NotImplementedError("Is {provider} implemented ?") |
ModuleNotFoundError | “A import related to provider X was not found. Is it installed ?” |
| 消息含 “refused” | “Server {ip} seem offline. Unable to answer.” |
| 消息含 “try again later” | “{provider} server is overloaded. Please try again later.” |
第一条尤其巧:Ctrl+C 被转成了 agent 认识的 REQUEST_EXIT 信号,让浏览器 agent 之类的循环能干净退出,而不是把异常炸穿整个调用栈。
test provider
test_fn 返回一段硬编码的 JSON 计划(sources/llm_provider.py:544-551),内容是「查大阪和东京的 AI 创业公司,写进 research_japan.txt」。把 config.ini 的 provider_name 设成 test,就能不花一分钱、不等一秒推理地跑通整条 planner 流水线。测试 agent 系统时这招很实用。
附带的自建 LLM 服务器
llm_server/ 是给 provider_name = server 用的一个 Flask 小服务(README 已标注 deprecated)。协议是三段式轮询:
POST /setup {model: ...} 设模型
POST /generate {messages: [...]} 起一个后台线程开始生成
GET /get_updated_sentence 每 2 秒轮询一次,直到 is_complete
实现见 llm_server/app.py 与 llm_server/sources/generator.py(GeneratorLLM.start 用 threading.Lock 保证同时只有一个生成任务)。客户端侧的轮询在 sources/llm_provider.py:126-164(server_fn)。
llm_server/sources/cache.py里的Cache有个明显 bug:__init__把缓存读成set(...),而add_message_pair却调self.cache.append(...)(set 没有 append)。而且GeneratorLLM.__init__里cache = Cache()是局部变量,从未被使用——这段缓存逻辑目前是死代码。
5.2 Memory:一个消息列表 + 两个可选功能
基本结构
self.memory = [{'role': 'system', 'content': system_prompt}]
每个 agent 独占一个 Memory 实例,系统提示词来自各自的 prompts/base/*.txt(或 prompts/jarvis/*.txt,取决于 jarvis_personality)。
push() 存的东西比标准 chat 格式多两个字段(sources/memory.py:159-174):
if config["MAIN"]["provider_name"] == "openrouter":
self.memory.append({'role': role, 'content': content})
else:
self.memory.append({'role': role, 'content': content,
'time': time_str, 'model_used': self.model_provider})
为什么 openrouter 要特判:多数后端会忽略消息里的未知字段,OpenRouter 不会。这是一处被具体后端逼出来的分支。
push 还会检查和上一条内容是否完全相同,是就打印警告(但仍然照存)——这是对循环里重复推同一条 prompt 的一个诊断。
返回值是 curr_idx-1,即新消息之前那条的索引。BrowserAgent 拿它当 mem_begin_idx 用(sources/agents/browser_agent.py:361),不过现在的代码里这个变量拿到之后并没有被消费。
上下文长度靠猜
这是全项目最「土法」的一段(sources/memory.py:47-68,get_ideal_ctx):
def extract_number_before_b(sentence: str) -> int:
match = re.search(r'(\d+)b', sentence, re.IGNORECASE)
return int(match.group(1)) if match else None
model_size = extract_number_before_b(model_name)
if not model_size:
return None
base_size = 7 # 基准 7B
base_context = 4096 # 基准 4096 token
scaling_factor = 1.5
context_size = int(base_context * (model_size / base_size) ** scaling_factor)
context_size = 2 ** round(math.log2(context_size)) # 圆到 2 的幂
从模型名里正则抠出 “14b” 这样的数字,按 1.5 次幂缩放,再圆到最近的 2 的幂。代入几个值:
| 模型名 | 抠出的规模 | 估算上下文 |
|---|---|---|
deepseek-r1:7b | 7 | 4096 |
deepseek-r1:14b | 14 | 16384(11585 圆到 2^14) |
qwen:32b | 32 | 32768 |
gpt-4o | 抠不出来 | None → 压缩与截断全部跳过 |
源码注释自己标了 “EXPERIMENTAL”。这个公式和真实模型的上下文窗口没有必然联系——它只是个「越大的模型大概能塞越多」的粗糙代理。
压缩:本地摘要模型
开启 memory_compression 时会下载 pszemraj/led-base-book-summary(sources/memory.py:70-75),然后:
push() 时:新内容长度 > ideal_ctx * 1.5 → 触发 compress()
|
compress():遍历所有非 system 消息 |
content 长度 > 1024 的 → summarize() 就地替换
(sources/memory.py:159-165、236-247)
注意这是破坏性的:原文被摘要覆盖,不可逆。
但实际上这条路径基本走不到。 Memory.__init__ 的形参默认值确实是 True(sources/memory.py:26),可六个 agent 在构造它时全部显式传了 False:
| agent | 传参位置 | 默认注册吗 |
|---|---|---|
CasualAgent | sources/agents/casual_agent.py:23 | 是 |
CoderAgent | sources/agents/code_agent.py:35 | 是 |
FileAgent | sources/agents/file_agent.py:24 | 是 |
BrowserAgent | sources/agents/browser_agent.py:43 | 是 |
PlannerAgent | sources/agents/planner_agent.py:35 | 是 |
McpAgent | sources/agents/mcp_agent.py:28 | 否(cli.py:52-54 注释掉,api.py 未 import) |
也就是说任何 默认部署下都没有一个 agent 打开压缩。全仓库唯一传 True 的地方是 sources/memory.py:276 的 __main__ 自测块。McpAgent 为什么不算数,见 01-routing.md §1.8。
浏览器 agent 用的是更朴素的硬截断(trim_text_to_max_ctx,sources/memory.py:249-254):
ideal_ctx = self.get_ideal_ctx(self.model_provider)
return text[:ideal_ctx] if ideal_ctx is not None else text
调用点在 BrowserAgent.get_page_text(limit_to_model_ctx=True),旁边还留着被注释掉的 compress_text_to_max_ctx 那一行(sources/agents/browser_agent.py:254-256)——摘要压缩在实践中被换成了直接切。
顺带一提,
ideal_ctx的单位是 token,但text[:ideal_ctx]切的是字符。这个不匹配是保守方向(切得比需要的更短),所以不会溢出,但会白白丢内容。
会话持久化
save_memory(agent_type)
<runtime>/conversations/<agent_type>/memory_<YYYY-MM-DD_HH-MM-SS>.txt
内容是整个 memory 列表的 json.dumps
load_memory(agent_type)
列出该目录下 memory_ 开头的文件 → 按文件名里的日期倒序 → 取第一个
如果最后一条是 user 角色 → pop 掉(那是一个没被回答的提问)
然后 compress() 一次
(sources/memory.py:81-153)
两个细节:
- 排序用的是文件名字符串(
saved_sessions.sort(key=lambda x: x[1], reverse=True),其中x[1]是filename.split('_')[1],即日期部分YYYY-MM-DD)。同一天的多个会话只能靠字符串比较区分,而时间部分在split('_')[2],没参与排序。所以同一天内恢复的未必是最新那次。 - 触发点在
Interaction的两个方法上(sources/interaction.py:184-194):Interaction.load_last_session()逐个 agent 载入并跳过planner_agent(原因见 03-planner.md §3.8),Interaction.save_session()逐个存、不跳过。这两个方法调不调,由config.ini里同名的两个开关recover_last_session/save_session决定,默认都是False(cli.py:60、cli.py:70-75、api.py:145、api.py:300-301)。注意recover_last_session同时也是Memory.__init__的参数名,但所有 agent 都传False(例如sources/agents/code_agent.py:34),恢 复只走Interaction这一条路。
5.3 运行时目录:日志、截图、会话存哪
sources/workspace.py 把两类目录彻底分开:
| 目录 | 放什么 | 由谁决定 |
|---|---|---|
WORK_DIR | agent 能读写的工作区 | 环境变量 WORK_DIR → config.ini 的 work_dir → 默认 <runtime>/workspace |
AGENT_RUNTIME_DIR | 日志、截图、会话历史 | 环境变量,默认 .agent-data |
(get_work_dir / get_runtime_dir,sources/workspace.py:28-41)
模块顶部的注释写明了动机:
“Application runtime data (logs, screenshots, conversation history) lives in AGENT_RUNTIME_DIR so the application source tree can stay read-only in Docker.”
于是 Logger(sources/logger.py:9-10)、Memory(sources/memory.py:33)、Browser(sources/browser.py:292)、api.py:72 全都通过 runtime_subdir(name) 拿路径,谁也不往仓库目录里写东西。
tests/test_workspace.py:49(test_runtime_subdir_is_outside_work_dir)专门守着这条不变量。
5.4 语音这一侧(简述)
虽然不是核心,但有一处设计值得单独看。
TTS:sources/text_to_speech.py 用 kokoro 本地合成,说话时把内容规范化后存进 self.last_spoken_text(sources/text_to_speech.py:79)。
STT:sources/speech_to_text.py 用 Vosk 本地识别。
回声过滤:麦克风会听见音箱里自己刚说的话。sources/echo_filter.py 的做法是双向找 3 个连续词的公共子串:
# 示意,非源码:双向都查一遍
for i in range(len(text_words) - 3 + 1):
if " ".join(text_words[i:i+3]) in last_spoken:
return True # 听到的话出现在刚说的话里
for i in range(len(spoken_words) - 3 + 1):
if " ".join(spoken_words[i:i+3]) in normalized_text:
return True # 刚说的话出现在听到的话里
真实实现在 sources/echo_filter.py(is_echo)。为什么要双向:STT 可能只识别出 TTS 说的一小段(子集),也可能识别出更长的一串(超集),单向包含判定会漏。
调用点在 Speech2Text.get_result(last_spoken, ...)(sources/speech_to_text.py:225-255),判为回声就当没听见。
CLI 的语音输入流程是「等唤醒词 → 一直收集 → 听到确认短语才提交」(Interaction.transcription_job,sources/interaction.py:225-253),确认短语表有 20+ 条(do it / go ahead / that's all / let's go …)。
5.5 本章代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 后端 dispatch | sources/llm_provider.py | Provider.respond、Provider.available_providers |
| 本地后端 | sources/llm_provider.py | ollama_fn、lm_studio_fn、openai_fn |
| 云端后端 | sources/llm_provider.py | anthropic_fn、litellm_fn、minimax_fn、openrouter_fn |
| Docker 网络 | sources/llm_provider.py | get_internal_url |
| 测试用假后端 | sources/llm_provider.py | test_fn |
| 自建 LLM 服务 | llm_server/app.py、llm_server/sources/generator.py | GeneratorLLM.start、GenerationState |
| 记忆存取 | sources/memory.py | Memory.push、save_memory、load_memory |
| 上下文估算与压缩 | sources/memory.py | get_ideal_ctx、compress、trim_text_to_max_ctx |
| 会话恢复/保存的调用方 | sources/interaction.py | Interaction.load_last_session、Interaction.save_session |
| 目录解析 | sources/workspace.py | get_work_dir、get_runtime_dir、runtime_subdir |
| 回声过滤 | sources/echo_filter.py | is_echo、filter_echo |
| 语音输入流程 | sources/interaction.py | transcription_job、initialize_tts |
| 记忆测试 | tests/test_memory.py | — |