跳到主要内容

运行时底座:加密库 · 设置快照 · LLM 提供方 · Web 编排 · API

30 秒导读: 前面几章讲「研究引擎怎么想、搜索引擎怎么找、报告怎么写」。这一章讲让它们真正跑起来的平台层——多用户、隐私优先、可配置。核心有五块:每个用户一份 SQLCipher 加密数据库(密码就是解密密钥,服务器从不存密码);研究在后台线程里只读一份冻结的设置快照而不碰数据库;LLM 按 provider 名从注册表里造出来(内置 provider 靠自动发现);Flask 应用工厂把请求接进来、丢给后台守护线程跑研究、用 WebSocket 实时推进度;还有一个自动处理 CSRF/登录的 HTTP 客户端给脚本用。安全出站管制(egress)是另一套,单独放在 06

本章隶属 Local Deep Research 的架构解剖。研究主线看 01,搜索层看 02,LangGraph 智能体看 03,引用合成看 04。总览见 index


1. 这层是什么(零基础也能懂)

一句话定义: 运行时底座 = 研究引擎之外、支撑「多用户 + 隐私 + 可配置 + 能远程调用」的所有基础设施。

它要解决的现实问题,可以拆成四个独立诉求(不要挤成一句):

诉求平台层怎么答
多个用户共用一台部署,数据不能互相看见每人一份独立的加密数据库文件
用户的 API key、研究历史是隐私,连服务器管理员也不该随手读到用户密码派生密钥,SQLCipher 全盘 AES-256 加密;密码从不落库
研究在后台跑几分钟,期间用户可能改设置,不能让它读到「跑一半变了的配置」开跑前抓一份只读快照,整轮研究只认这份快照
既要能在浏览器里点,也要能被 Python 脚本 / CI 调用一套 Flask Web + 一套 HTTP 客户端,同一后端

用起来什么样(直观感受)。 一个最小的编程式调用:

# 示意,基于 api/client.py 的真实签名
from local_deep_research.api.client import quick_query

# 用户名 + 密码登录(密码用来解密该用户的数据库),问一个问题,拿回摘要
summary = quick_query("alice", "her-password", "什么是量子纠错?")
print(summary)

一句话直觉/类比。 把每个用户的数据库想成一个上了密码锁的保险箱:密码不是存在门口的名册上(那样管理员偷看名册就能开箱),而是密码本身就是钥匙的形状——你报对密码,箱子才打得开;报错了,箱子纹丝不动,且从外面看不出你是「密码错」还是「箱子根本不存在」。


2. 顶层全景(它大概怎么转)

先看一次「浏览器发起研究」从进门到落库、再到实时推送的全链路。怎么读这张图:从左到右是请求 → 编排 → 后台线程 → 回推;竖线上方是主请求线程(能碰 DB),下方是后台守护线程(只认快照)。

浏览器 Flask 请求线程(有 DB 会话) 后台守护线程(daemon)
────── ──────────────────────────── ──────────────────────
POST /research/ start_research() run_research_process()
api/start_research ─► ①抓设置快照 get_all_settings ──┐ │ ⑤set_search_context(密码/快照)
②写 ResearchHistory 到用户库 │ │ ⑥SnapshotSettingsContext +
③start_research_process ────────┼──spawn─►│ set_settings_context(线程本地)
④返回 research_id (200) │ 快照 │ ⑦跑研究引擎(01-04 章)
│ +密码 │ └► get_llm() 造模型
WebSocket ◄──────── SocketIOService.emit_to_ │ │ └► 搜索/token 度量落用户库
progress_<id> subscribers(增量进度) ◄───────────┼─────────┤ ⑧cleanup_research_resources
│ │ 发终态消息 + 清订阅
▼ ▼
db_manager: 每用户 SQLCipher Engine

主要部件、职责、落在哪个文件:

部件干什么主文件
DatabaseManager每用户加密库的开/建/关、连接池、改密src/local_deep_research/database/encrypted_db.py
SQLCipher 工具密钥派生、PRAGMA 序列、salt 管理src/local_deep_research/database/sqlcipher_utils.py
SettingsManager / 快照读写设置、生成冻结快照src/local_deep_research/settings/manager.py
线程设置上下文后台线程只从快照/线程本地取设置src/local_deep_research/config/thread_settings.py
get_llm + 注册表 + 自动发现按 provider 造 chat modelsrc/local_deep_research/config/llm_config.pyllm/
Flask 应用工厂 + 路由 + 后台线程Web 编排全链路src/local_deep_research/web/
SocketIOServiceWebSocket 实时进度src/local_deep_research/web/services/socket_service.py
LDRClient / quick_query编程式 HTTP 访问src/local_deep_research/api/client.py
度量追踪token/搜索/成本落进用户库src/local_deep_research/metrics/

3. 每用户加密数据库(密码即密钥,从不落库)

3.1 它要解决的小问题

多用户 + 隐私优先意味着:每个人的研究历史、API key、设置都不能被别人(包括拿到磁盘文件的人、甚至管理员)读到。传统做法是「存密码哈希 + 应用层做权限判断」,但那样数据本身仍是明文躺在盘上。LDR 走得更狠:数据整盘加密,密码就是唯一的解密钥匙,服务器不保存密码的任何形式

3.2 思路/直觉:登录 = 尝试解密

关键的心智模型:「验证密码对不对」和「解密数据库」是同一个动作

  • 没有 password_hash 列。User 模型只存用户名和元数据,注释明写「passwords are NEVER stored here」(database/models/auth.py:17 class User)。
  • 登录时服务器拿密码去尝试打开该用户的加密库:解密成功 = 密码对;解密失败 = 密码错。见登录路由 web/auth/routes.py:227(engine = db_manager.open_user_database(username, password))。
  • 改密码时只 rekey 数据库,没有任何「更新密码哈希」的步骤(encrypted_db.py:976 DatabaseManager.change_password,方法 docstring 明说「no separate auth-DB password-hash update is needed because passwords are never stored」)。

3.3 密钥怎么从密码来(PBKDF2 → SQLCipher key)

每个用户一个文件,文件名是用户名的 SHA-256 前 16 位(避免特殊字符问题):config/paths.py:135 get_user_database_filename 返回 ldr_user_<hash>.db

密钥派生链条(白话:密码 + 每库随机盐,反复哈希几十万次,得到一把固定长度的钥匙):

用户密码 ─► PBKDF2-HMAC-SHA512(password, salt, kdf_iter=256000) ─► 32B key ─► PRAGMA key = x'<hex>'
▲ ▲ ▲
sqlcipher_utils 每库 .salt 文件 set_sqlcipher_key
get_key_from_password (新库随机生成) (十六进制,避开注入/转义)
  • 派生本体:sqlcipher_utils.py:133 _get_key_from_password,用标准库 pbkdf2_hmac("sha512", ...)。默认 25.6 万次迭代(DEFAULT_KDF_ITERATIONS = 256000,sqlcipher_utils.py:263)。
  • 每库独立随机盐(v2 安全改进):建库时 create_database_salt 写一个 32 字节随机盐到 <db>.salt(sqlcipher_utils.py:77),读时 get_salt_for_database(:42)。老库没有 .salt 文件时回退到 LEGACY_PBKDF2_SALT = b"no salt"(:307),并打一条 deprecation 警告——这个常量绝不能改,改了所有老库都开不了
  • 把密钥交给 SQLCipher:set_sqlcipher_key(:196)用 PRAGMA key = "x'<hex>'" 传十六进制而非明文口令,注释点明这是为了「避免特殊字符的 SQL 注入 / 转义问题」。

3.4 真实实现:开库的冷启动路径

DatabaseManager.open_user_database(encrypted_db.py:630)是登录的入口。几个不显然的设计:

# 真实源码节选,encrypted_db.py:680-688(_open_user_database_cold)
# Prevent timing attacks: always derive key before checking file existence
hex_key = get_key_from_password(password, db_path=db_path).hex()
if not db_path.exists():
logger.error(f"No database found for user {username}")
return None
  • 抗时序攻击:无论用户是否存在,都先做一次(昂贵的)PBKDF2 派生再检查文件是否存在,这样「用户不存在」和「密码错」耗时一致,防止靠计时枚举用户名。
  • 两类失败要分清:凭据无效 / 库不存在 → 返回 None(登录路由记一次锁定计数、返回 401);凭据有效但 schema 迁移失败 → 抛 DatabaseInitializationError(:54),登录路由返回 503 且不计入锁定计数(web/auth/routes.py:228)。凭据对却因服务器配置问题被锁号是不公平的。
  • 密钥先派生、闭包只捕获 hex_key:引擎的连接创建闭包 create_open_connection 捕获的是 hex_key(已派生的十六进制),不是明文密码(:702)。这样引擎对象上不残留明文口令。
  • 每用户冷启动锁:_get_init_lock(:614)给每个用户一把锁,序列化「建引擎 + 跑 alembic 迁移」,避免两个并发首开对同一个库文件同时迁移。

连接本身走 create_sqlcipher_connection(sqlcipher_utils.py:592),它实现完整的 PRAGMA 序列:新库是 cipher_default_* → key → kdf_iter → 性能 pragma → 校验,老库是 key → cipher_* + kdf_iter → 性能 → 校验。顺序有讲究——cipher 参数必须在第一次触发解密的查询之前配好。

3.5 关键细节 / 坑

  • 日志绝不能泄露密码。 明文密码会出现在多个函数的栈帧局部变量里,loguru 的 diagnose=True 会 dump 帧局部。所以所有 except 块统一用 logger.warning(丢掉 traceback)+ redact_secrets(str(e), password) 兜底(如 encrypted_db.py:498:585:805)。密码不可恢复,泄露是永久性的。
  • 连接池选型:生产用 QueuePool(pool_size=20, max_overflow=40),测试用 StaticPool(encrypted_db.py:149)。ADR-0004 注释里解释了为什么是 20 而不是 1(UI 每 1-2 秒轮询状态,pool_size=1 会被并发请求打爆)、为什么不用 NullPool(SQLCipher 每次开连接的 PRAGMA key 要 ~0.2ms)。
  • SQLCipher 不可用时的兜底:默认拒绝启动(_check_encryption_available 抛 RuntimeError,:293),除非显式设 LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true 才降级成明文 SQLite,并反复告警。
  • models/ 概览:所有用户库表定义在 database/models/(research.py 研究历史、metrics.py token/搜索度量、settings.py 设置、chat.pylibrary.pynews.pyactive_research.py 等);建库时从 Base.metadata 直接生成 CREATE TABLE(encrypted_db.py:460),users 表除外(它在独立的 auth 库里)。

4. 设置快照体系(后台线程只读冻结快照)

4.1 它要解决的小问题

研究在后台线程里跑几分钟。这期间:(a) 用户可能在另一个标签页改设置;(b) 后台线程本来就不该随便开加密库读设置(要密码、要连接、有竞争)。所以需要:开跑前把「本轮要用的全部设置」一次性冻成一份普通 dict,整轮只认它。

4.2 三种「设置视图」别混

视图是什么谁用
SettingsManager活的、连着 DB 会话、能读能写主请求线程
快照 dict冻结的 {key: value}(或 {key: {"value":..., "ui_element":...}})传给后台线程 / 存进研究元数据
SnapshotSettingsContext包住快照、只读、get_setting(key, default)后台线程内

4.3 快照怎么生成、怎么解包(unwrap)

生成。 请求线程在开跑前调 get_all_settings(bypass_cache=True)——它把 defaults JSON 和 DB 行合并成「完整格式」(每个 key 是带 value/ui_element/options/... 的 dict),见 settings/manager.py:816。研究路由拿到后塞进 research_settings["settings_snapshot"](web/routes/research_routes.py:702-709)。还有个简化版 get_settings_snapshot()(:951)只保留 {key: value}

为什么带 ui_element? 因为 SQLite 没有类型,DB 里存的可能是字符串 "3",而设置逻辑上是整数。ui_element(如 "number""checkbox")是恢复类型的依据

解包。 后台线程通过 get_setting_from_snapshot(config/thread_settings.py:71)取值,它同时兼容两种格式:

# 真实源码节选,thread_settings.py:97-105
raw = settings_snapshot[key]
if isinstance(raw, dict) and "value" in raw:
value = get_typed_setting_value(key, raw["value"], raw.get("ui_element", "text"))
else:
value = raw # 简化快照:直接就是值

单值解包的独立小工具是 utilities/type_utils.py:59 unwrap_setting——它刻意放在这个无内部依赖的叶子模块里,好让底层调用方(egress 策略、搜索引擎工厂、通知)都能 import 而不触发 api 包的循环导入。

一个易踩的坑(#4208)。 「key 不在快照」和「key 在、值就是 None」必须区分开:embeddings 的某些设置默认存 JSON null,如果用 None 同时表示两者,合法的 null 值会被误判成「没找到」而在无上下文的请求线程里抛异常。所以用了一个哨兵对象 _NOT_FOUND = object()(thread_settings.py:28)而不是 None

4.4 后台线程怎么装上这份快照

run_research_process(web/services/research_service.py:802)是后台线程的入口:

# 真实源码节选,research_service.py:911-927
settings_context = SnapshotSettingsContext(settings_snapshot, username=username)
...
from ...config.thread_settings import set_settings_context
set_settings_context(settings_context) # 挂到 threading.local()

SnapshotSettingsContext.__init__(settings/manager.py:1420)在构造时就把 {"value": x} 拍平成 self.values[key] = x,之后 get_setting 纯查 dict、绝不碰 DB。set_settings_context 把它塞进 threading.local()(thread_settings.py:31),配套的 settings_context() 上下文管理器保证 finally 里清理,避免线程池复用时上下文泄露给下一个任务(:53)。


5. LLM 提供方抽象(按 provider 造 chat model)

5.1 它要解决的小问题

用户可能用 Ollama(本地)、OpenAI、Anthropic、LM Studio、llama.cpp、或任意 OpenAI 兼容端点(xAI / OpenRouter / IONOS / Groq…)。研究引擎不该关心这些差异,它只想说一句「给我一个能 .invoke() 的聊天模型」。

5.2 思路:注册表 + 自动发现,单一真源

核心入口是 get_llm(config/llm_config.py:54)。它返回的是一个包了一层的 LangChain 模型:自动去 <think> 标签、可选限流、token 计数回调都在这层加(wrap_llm_without_think_tags,:319)。

provider 从哪来?不是硬编码列表,而是自动发现:

llm/providers/implementations/*.py 每个文件一个 Provider 类
│ (import 时扫描)

ProviderDiscovery.discover_providers() ── 反射找出所有 *Provider 子类
│ register_llm(key, cls.create_llm)

LLMRegistry(全局, 线程安全) ◄── get_llm 里 is_llm_registered(provider) 查它
  • 自动发现:llm/providers/auto_discovery.py:72 discover_providersimportlib + inspect.getmembersimplementations/ 目录,凡是继承 BaseLLMProviderprovider_name 不是 "unknown"、且定义在本模块(obj.__module__ == module.__name__,防止把 import 进来的基类重复注册)的类,都调 register_llm(key, cls.create_llm) 注册。
  • 注册表:llm/llm_registry.py:14 LLMRegistry,大小写不敏感、带锁。
  • 单一真源:get_llm 校验 provider 是否合法时,直接从 get_discovered_provider_options() 派生集合(llm_config.py:245),注释明说刻意不保留单独的 VALID_PROVIDERS 常量——那会是「第三份 provider 列表」,历史上真的漂移过(xai/ionos/deepseek 合法却漏在里面)。

5.3 Provider 基类的契约

BaseLLMProvider(llm/providers/base.py:19)定义最小接口。几个约定值得记:

  • create_llm() 返回的是 LangChain 模型;限流/计数/去 think 标签由 get_llm() 在外面加,子类不要自己包(base.py:52)。
  • API key 解析统一走 resolve_api_key(:110):必填 provider 缺 key 就抛 ValueError;可选 provider(本地 LM Studio / llama.cpp,可能没开鉴权)缺 key 返回 None,由 resolve_api_key_or_placeholder(:137)补上统一占位符 OPTIONAL_API_KEY_PLACEHOLDER = "not-required"(:7)——注释说这是为了消除历史上三个不同占位串的漂移。
  • is_available() 默认返回 False(fail-closed),子类必须自己覆写。

5.4 OpenAI 兼容端点(覆盖大半 provider)

OpenAICompatibleProvider(llm/providers/openai_base.py:17)是 Google/OpenRouter/Groq/Together/xAI 等一切「OpenAI 兼容 API」的公共实现,子类只改 url_settingdefault_base_url 等类属性。create_llm(:33)的要点:

  • 不给默认模型:model_name 为空直接抛错,拒绝静默 fallback 到某个硬编码模型(:61)。
  • SSRF 防护:对可由 operator 配置的 base_urlassert_base_url_safe(:78)——但只在 url_setting 非空时(OpenAI/Anthropic 用硬编码 URL,没有可攻击的 operator URL)。这条通往 06 章的出站管制。
  • context window 感知的 max_tokens 上限(留 20% 给 prompt 本身),经 compute_max_tokens(:103)。

5.5 egress PEP:LLM 构造点的策略执行

get_llm 里嵌了一个策略执行点(PEP):没有快照时,非本地 provider 一律 fail-closed 拒绝(llm_config.py:131);有快照时,若本轮研究要求「本地 LLM」,就用 evaluate_llm_endpoint 判端点是否放行(:176)。细节属安全出站,详见 06


6. Web 编排全链路(Flask 工厂 → 后台线程 → WebSocket)

6.1 应用工厂与 before_request 链

create_app(web/app_factory.py:51)是 Flask 应用工厂,返回 (app, socketio)。它把 stdlib / werkzeug / apscheduler 的日志接进 loguru,注册蓝图(register_blueprints,:563),并挂一串 before_request 中间件——顺序有意义:

cleanup_stale_sessions → ensure_user_database → inject_current_user
→ cleanup_completed_research → process_pending_queue_operations → notify_queue_processor

(app_factory.py:424-434)。inject_current_user 负责把当前用户 + 懒创建的 DB 会话挂到 Flask g 上(见 database/session_context.py:55 get_g_db_session,遵循 Flask「用到才 checkout 连接」的懒加载模式)。

6.2 从请求到后台线程

发起研究的路由是 POST /research/api/start_research(web/routes/research_routes.py:461 start_research)。它在主请求线程里完成必须碰 DB 的事,然后把剩下的丢给后台线程:

  1. 抓快照:用 g 上的会话建 SettingsManager,get_all_settings(bypass_cache=True),应用本轮 egress 覆盖(_apply_policy_overrides,表单值只覆盖本轮、不落库),存进 research_settings["settings_snapshot"](:702-709)。抓不到快照就直接 500——线程化研究没有快照没法跑(:757)。
  2. 写研究记录:ResearchHistory 落用户库,拿到 research_id(:781)。
  3. 起线程:start_research_process(research_service.py:322)。

start_research_process 的几个设计点:

  • 全局并发信号量在调用线程里同步获取:_global_research_semaphore.acquire(blocking=False) 抢不到就抛 SystemAtCapacityError(路由据此返回 429),:351。历史上这个 acquire 在 worker 里做,导致 HTTP 已返回 200 但 worker 卡住、用户看到无限转圈。
  • app context 透传 + 信号量出口释放:thread_with_app_context 包住回调,再包一层 _release_semaphore_on_exit(finally 里 release,:362)。
  • 原子 check-and-start:check_and_start_research(:386)拒绝为同一 research_id 起第二个活线程,防重复派发。
  • 线程是 daemon = True(:379)。

6.3 后台线程内:先立上下文,再跑

run_research_process(research_service.py:802)进来第一件事set_search_context(:834),把 research_id / username / user_password / settings_snapshot 挂到线程本地——必须最先做,否则早期的 INFO 日志想写进用户加密的 ResearchLog 却开不了库(没密码),被静默丢弃。之后才建 SnapshotSettingsContextset_settings_context(§4.4),再跑研究引擎(01-04 章)。

这里有两条平行的线程本地别混:

线程本地装什么谁读
thread_context(search context)密码、research_id、username度量落库、日志、DB 会话兜底取密码
thread_settings(settings context)冻结的 SnapshotSettingsContext所有 get_setting_from_snapshot 调用

6.4 WebSocket 实时进度

SocketIOService(web/services/socket_service.py:62)封装 Flask-SocketIO。进度推送走 emit_to_subscribers(:235):

  • 只发给订阅者、不广播:事件名是 {event_base}_{research_id},只 emit 到订阅了该 research 的 socket id(:257-272),避免跨用户串消息、也减负载。
  • 早到事件直接丢:还没有订阅者时不 broadcast——客户端一 subscribe 就会收到「补发的最新进度快照」(__handle_subscribe 的 catch-up),所以早到的进度不会丢(:277)。
  • CORS 可由 LDR_WEB_... 环境变量收紧,非 * 时装一个来源拒绝日志(:14:138)。

6.5 终止与清理

研究正常结束、用户终止、或出错,都汇到 cleanup_research_resources(research_service.py:2746)。关键一点:终态状态由调用方传进来,而不是硬编码 COMPLETED:

# 真实源码,research_service.py:2746
def cleanup_research_resources(research_id, username=None, user_password=None,
final_status=ResearchStatus.COMPLETED):
  • 用户终止传 SUSPENDED、出错传 FAILED;这些情况下最终 socket 消息的 progress 报 0 而不是 100(:2820-2828),否则会给订阅者发一个假的「完成」信号。
  • 通知队列处理器在主线程里更新 DB 状态(避开后台线程的 Flask 上下文问题),清理 active/termination 标志,发终态消息,移除 socket 订阅(:2842-2847)。

7. 编程式访问(HTTP 客户端 + 便捷函数)

7.1 为什么要一个客户端类

后端全是带 CSRF 保护的 Flask 路由。裸 requests 调用会被 CSRF 挡下。LDRClient(api/client.py:67)把这套复杂度藏起来:自动从登录页 HTML 里抠 CSRF token、管 cookie、给后续请求带 X-CSRF-Token 头。

7.2 登录 = 拿 token + 建会话

login(api/client.py:92)的三步:GET 登录页 → 正则抠出 csrf_token(不引入 BeautifulSoup)→ 用表单(不是 JSON)提交登录 → 存下后续用的 CSRF token(:108-140)。之后所有写操作经 _api_headers(){"X-CSRF-Token": token}(:165)。

值得注意:客户端用的是 SafeSession(allow_localhost=True)(:87)——这是 LDR 自己的、带 SSRF 校验的 requests 会话(见 06),连本地 LDR server 时显式放行 localhost。

7.3 便捷函数

  • quick_research(:171):POST 开研究 → 轮询 wait_for_research(每 5 秒查一次状态)→ 返回摘要/来源/发现。
  • quick_query(:457):一行式,内部用上下文管理器(自动登出),login → quick_research → result["summary"]
  • update_setting / get_settings(:312:302):PUT/GET /settings/api

7.4 进程内编程式研究(不走 HTTP)

api/research_functions.py 提供不经 Web 层的进程内调用:quick_summary(:166)、generate_report(:344)、detailed_research(:511)。它们的共同约定:必须有 settings_snapshot,否则用 create_settings_snapshot(...) 现造一个(:227-249),并默认 programmatic_mode=True——此时关掉 DB 度量追踪(_init_search_system,:48),因为没有登录用户/加密库可写。


8. 度量:token / 搜索 / 成本怎么落进用户库

8.1 挑战:后台线程里没有 Flask 会话,但要写加密库

度量(每次搜索、每次 LLM 调用的 token 和成本)产生在后台线程里,而写它们需要用户的加密库——也就需要密码。密码不在 Flask session 里(那是请求线程的东西),而在 §6.3 立好的线程本地 search context 里。

8.2 两个追踪器,同一套取数逻辑

  • 搜索:SearchTracker.record_search(metrics/search_tracker.py:25)。从 get_search_context()usernameuser_password(:69:78),任一缺失就跳过并告警(programmatic 模式下本就无上下文)。
  • token:TokenCountingCallback(metrics/token_counter.py:23)是 LangChain 回调,on_llm_end 里算 token,_save_to_db(:587)同样从 research_context 取 username/password(:594:645)。这个回调由 wrap_llm_without_think_tagsget_llm 里挂上(§5.2),所以每个经 get_llm 造出来的模型都自动计数。

两者都走线程安全的 metrics writer:

# 真实源码节选,search_tracker.py:87-93
from ..database.thread_metrics import metrics_writer
metrics_writer.set_user_password(username, password) # 设本线程密码
with metrics_writer.get_session(username) as session:
session.add(search_call) # SearchCall 落用户加密库

8.3 主线程路径:从 Flask session 取

同样的度量在请求线程里查询时(如指标页),走的是另一条:MetricsDatabase.get_session(metrics/database.py:21)——有密码就用 metrics_writer(线程安全),没密码就从 Flask session 取用户名走 get_user_db_session(:36:58)。这就是「同一份数据,请求线程和后台线程两条取数路径」的由来。


9. 巧妙之处(可借鉴的技术)

  • 登录即解密,消灭「密码存储」这个攻击面。 不存哈希、不存密文,验证和解密是同一动作;改密只 rekey。整个类别的「密码库泄露」在设计上不存在(encrypted_db.py:976database/models/auth.py:17)。
  • 抗时序枚举:先派生密钥再查文件是否存在。 把最贵的操作提前,让「用户不存在」和「密码错」等时(encrypted_db.py:684)。
  • 凭据失败 vs 初始化失败分成两个返回通道。 None(401,计锁定)对 DatabaseInitializationError(503,不计锁定)——服务器配置问题不该惩罚无辜用户(encrypted_db.py:54web/auth/routes.py:228)。
  • 冻结快照 + 哨兵值。 后台线程整轮只认一份 dict,不碰 DB;用 _NOT_FOUND 哨兵区分「缺 key」和「值为 None」(thread_settings.py:28)。
  • provider 单一真源。 合法 provider 集合从自动发现派生,拒绝维护第三份会漂移的列表(llm_config.py:245auto_discovery.py:137)。
  • 日志脱敏纪律。 每个可能持有密码的 except 块统一 logger.warning 丢 traceback + redact_secrets 兜底,防 loguru diagnose dump 帧局部(encrypted_db.py 多处)。
  • 占位符 API key 统一常量。 消除「三个不同占位串」的历史漂移(llm/providers/base.py:7)。

10. 边界与局限(诚实)

  • 密码丢了 = 数据永久不可恢复。 没有找回机制(没存密码);.salt 文件删了也一样开不了库(sqlcipher_utils.py:87 的 WARNING)。备份必须连 .salt 一起备。
  • KDF 迭代数不随库存储。 它在开库时从环境派生;若在已有真实数据的部署上把 KDF 降到 production floor 以下(如误设 LDR_TEST_MODE),新库变弱、且用更高 KDF 建的老库会再也开不了(派生出不同的弱密钥,登录报通用 401)。warn_if_weak_kdf_with_existing_databases(:367)只能检出「服务器现在偏弱」这一个方向。
  • 内存里密码并不受保护。 cipher_memory_security 默认 OFF(sqlcipher_utils.py:499),注释说得很直白:密码本就明文躺在 Flask session、db_manager、线程本地里,单给 SQLCipher 缓冲区上 mlock 意义不大。
  • SQLite 写串行 + SQLCipher+WAL 的 FD 泄露。 多连接不提升写吞吐;连接池刻意压到 20,并靠周期性 engine.dispose() 缓解 WAL 模式下的文件句柄泄露(encrypted_db.py:114 的 ADR-0004 注释)。
  • programmatic 模式无度量。 进程内 quick_summary 等默认 programmatic_mode=True,关掉 DB 追踪(api/research_functions.py:48),因为没有登录用户/加密库可写。
  • 代码里看不出的: 本章不覆盖出站网络的完整策略引擎(egress PEP/PDP)、SSRF 校验器实现、知识库与新闻订阅——这些在 06

11. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移(上游更新后行号会变,符号名通常还在)。

主题文件路径关键符号
每用户加密库开/建/关/改密src/local_deep_research/database/encrypted_db.pyDatabaseManageropen_user_database_open_user_database_coldcreate_user_databasechange_passwordDatabaseInitializationError
密钥派生 + PRAGMA 序列 + saltsrc/local_deep_research/database/sqlcipher_utils.pyget_key_from_password_get_key_from_passwordset_sqlcipher_keycreate_sqlcipher_connectioncreate_database_saltLEGACY_PBKDF2_SALT
无密码存储的用户模型src/local_deep_research/database/models/auth.pyUser
用户库文件名src/local_deep_research/config/paths.pyget_user_database_filenameget_data_directory
DB 会话上下文(Flask g / 线程本地)src/local_deep_research/database/session_context.pyget_g_db_sessionget_user_db_sessionwith_user_database
设置读写 + 快照生成src/local_deep_research/settings/manager.pySettingsManagerget_all_settingsget_settings_snapshotSnapshotSettingsContextget_typed_setting_value
线程内只读设置src/local_deep_research/config/thread_settings.pyget_setting_from_snapshotset_settings_contextsettings_context_NOT_FOUND
单值解包 / 布尔转换src/local_deep_research/utilities/type_utils.pyunwrap_settingto_bool
LLM 工厂 + 包装 + egress PEPsrc/local_deep_research/config/llm_config.pyget_llmwrap_llm_without_think_tagsget_selected_llm_provider
LLM 注册表src/local_deep_research/llm/llm_registry.pyLLMRegistryregister_llmis_llm_registered
provider 基类src/local_deep_research/llm/providers/base.pyBaseLLMProviderresolve_api_keyOPTIONAL_API_KEY_PLACEHOLDERnormalize_provider
OpenAI 兼容端点src/local_deep_research/llm/providers/openai_base.pyOpenAICompatibleProvidercreate_llm
provider 自动发现src/local_deep_research/llm/providers/auto_discovery.pyProviderDiscoverydiscover_providersget_discovered_provider_options
Flask 应用工厂src/local_deep_research/web/app_factory.pycreate_appregister_blueprints
研究起停编排src/local_deep_research/web/services/research_service.pystart_research_processrun_research_processcleanup_research_resources
发起研究路由src/local_deep_research/web/routes/research_routes.pystart_research_apply_policy_overrides
WebSocket 进度src/local_deep_research/web/services/socket_service.pySocketIOServiceemit_to_subscribers
登录 = 解密src/local_deep_research/web/auth/routes.pyopen_user_database 调用点、_create_user_session
HTTP 客户端src/local_deep_research/api/client.pyLDRClientloginquick_researchquick_queryupdate_setting_api_headers
进程内编程式研究src/local_deep_research/api/research_functions.pyquick_summarygenerate_reportdetailed_research_init_search_system
搜索度量src/local_deep_research/metrics/search_tracker.pySearchTracker.record_search
token 度量src/local_deep_research/metrics/token_counter.pyTokenCountingCallback_save_to_dbTokenCounter.create_callback
度量 DB 会话src/local_deep_research/metrics/database.pyMetricsDatabase.get_session