跳到主要内容

数据截至 (上游 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) -> strrespond() 查表调用(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)。

三类调用形态

形态后端特点
官方 SDKollama、anthropic、together、litellm各用各的库
OpenAI 兼容客户端openai、google、deepseek、openrouter、minimax都是 OpenAI(api_key=..., base_url=...),只换 base_url
裸 HTTPlm-studio、serverrequests.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_urlsources/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"
AttributeErrorNotImplementedError("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.iniprovider_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.pyllm_server/sources/generator.pyGeneratorLLM.startthreading.Lock 保证同时只有一个生成任务)。客户端侧的轮询在 sources/llm_provider.py:126-164server_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-68get_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:7b74096
deepseek-r1:14b1416384(11585 圆到 2^14)
qwen:32b3232768
gpt-4o抠不出来None → 压缩与截断全部跳过

源码注释自己标了 “EXPERIMENTAL”。这个公式和真实模型的上下文窗口没有必然联系——它只是个「越大的模型大概能塞越多」的粗糙代理。

压缩:本地摘要模型

开启 memory_compression 时会下载 pszemraj/led-base-book-summarysources/memory.py:70-75),然后:

push() 时:新内容长度 > ideal_ctx * 1.5 → 触发 compress()
|
compress():遍历所有非 system 消息 |
content 长度 > 1024 的 → summarize() 就地替换

sources/memory.py:159-165236-247

注意这是破坏性的:原文被摘要覆盖,不可逆。

但实际上这条路径基本走不到。 Memory.__init__ 的形参默认值确实是 Truesources/memory.py:26),可六个 agent 在构造它时全部显式传了 False

agent传参位置默认注册吗
CasualAgentsources/agents/casual_agent.py:23
CoderAgentsources/agents/code_agent.py:35
FileAgentsources/agents/file_agent.py:24
BrowserAgentsources/agents/browser_agent.py:43
PlannerAgentsources/agents/planner_agent.py:35
McpAgentsources/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_ctxsources/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 决定,默认都是 Falsecli.py:60cli.py:70-75api.py:145api.py:300-301)。注意 recover_last_session 同时也是 Memory.__init__ 的参数名,但所有 agent 都传 False(例如 sources/agents/code_agent.py:34),恢复只走 Interaction 这一条路。

5.3 运行时目录:日志、截图、会话存哪

sources/workspace.py 把两类目录彻底分开:

目录放什么由谁决定
WORK_DIRagent 能读写的工作区环境变量 WORK_DIRconfig.iniwork_dir → 默认 <runtime>/workspace
AGENT_RUNTIME_DIR日志、截图、会话历史环境变量,默认 .agent-data

get_work_dir / get_runtime_dirsources/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.”

于是 Loggersources/logger.py:9-10)、Memorysources/memory.py:33)、Browsersources/browser.py:292)、api.py:72 全都通过 runtime_subdir(name) 拿路径,谁也不往仓库目录里写东西。

tests/test_workspace.py:49test_runtime_subdir_is_outside_work_dir)专门守着这条不变量。


5.4 语音这一侧(简述)

虽然不是核心,但有一处设计值得单独看。

TTSsources/text_to_speech.py 用 kokoro 本地合成,说话时把内容规范化后存进 self.last_spoken_textsources/text_to_speech.py:79)。

STTsources/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.pyis_echo)。为什么要双向:STT 可能只识别出 TTS 说的一小段(子集),也可能识别出更长的一串(超集),单向包含判定会漏。

调用点在 Speech2Text.get_result(last_spoken, ...)sources/speech_to_text.py:225-255),判为回声就当没听见。

CLI 的语音输入流程是「等唤醒词 → 一直收集 → 听到确认短语才提交」(Interaction.transcription_jobsources/interaction.py:225-253),确认短语表有 20+ 条(do it / go ahead / that's all / let's go …)。


5.5 本章代码地图

主题文件符号
后端 dispatchsources/llm_provider.pyProvider.respondProvider.available_providers
本地后端sources/llm_provider.pyollama_fnlm_studio_fnopenai_fn
云端后端sources/llm_provider.pyanthropic_fnlitellm_fnminimax_fnopenrouter_fn
Docker 网络sources/llm_provider.pyget_internal_url
测试用假后端sources/llm_provider.pytest_fn
自建 LLM 服务llm_server/app.pyllm_server/sources/generator.pyGeneratorLLM.startGenerationState
记忆存取sources/memory.pyMemory.pushsave_memoryload_memory
上下文估算与压缩sources/memory.pyget_ideal_ctxcompresstrim_text_to_max_ctx
会话恢复/保存的调用方sources/interaction.pyInteraction.load_last_sessionInteraction.save_session
目录解析sources/workspace.pyget_work_dirget_runtime_dirruntime_subdir
回声过滤sources/echo_filter.pyis_echofilter_echo
语音输入流程sources/interaction.pytranscription_jobinitialize_tts
记忆测试tests/test_memory.py