模型与辅助系统设计
记录清海 AI 的 LLM 调用链路(模型调用现状、统一方案、模型切换策略)、记忆与知识系统(PersonContext / Memory / Knowledge / Daily Notes / context providers 的边界与协作)、以及技能系统(渐进式信息披露、常驻工具与按需技能)的完整设计。 如本文与《产品与架构设计》冲突,以后者为准。 最后更新:2026-03-18
一、设计原则
自己封装 HTTP 调用,不用官方 SDK。
原因:
- 上下文必须可控:清海的对话上下文组装包含固定规则、人物切片、会话简报、Concern 摘要、按需业务上下文和记忆检索等分层内容,并受可见性裁剪约束;token 预算、Pruning、Compaction 都要求精确控制每次发给模型的 messages 内容。具体分层以《对话流程设计》《权限与可见性设计》为准。官方 SDK 自己管上下文,无法介入
- 中转站要求特定请求头:项目通过本地代理(
ANTHROPIC_BASE_URL)访问模型,中转站对 headers 有限制,需要模拟 Claude Code CLI 格式 - 灵活切换模型和 provider:不绑死某个 SDK,底层走标准 HTTP,换模型只改配置
二、当前现状:三条独立链路
链路一:主对话(scripts/direct/client.py)
| 项目 | 说明 |
|---|---|
| 用途 | 所有用户对话(TG、飞书等) |
| 类 | DirectClaudeClient |
| HTTP | httpx 异步 |
| 流式 | SSE 流式解析(自己实现 _iter_sse()) |
| Agent Loop | 有,流式响应 → tool_use → 执行工具 → 回传结果 → 继续循环 |
| 认证 | 环境变量 ANTHROPIC_AUTH_TOKEN |
| 端点 | {ANTHROPIC_BASE_URL}/v1/messages |
| 多 provider | 不支持,写死环境变量 |
| Session | 本地管理对话历史,持久化到 MongoDB |
| Hooks | on_user_prompt(记忆检索)、on_stop(保存对话)、on_pre/post_tool_use |
调用链:
用户消息
→ avatar_core.call_claude_avatar()
→ LiveSessionManager.query() [tg_claude_sdk.py, 桥接层]
→ DirectSessionManager.query() [direct/session.py]
→ DirectClaudeClient.agent_loop() [direct/client.py]
→ _stream_api() [httpx POST + SSE]链路二:内部 LLM 调用(scripts/llm/client.py)
| 项目 | 说明 |
|---|---|
| 用途 | 记忆检索打分、session brief 更新等内部调用 |
| 函数 | call_llm() |
| HTTP | requests 同步 |
| 流式 | 非流式,等完整响应 |
| Agent Loop | 无 |
| 认证 | .config.json 中 llm_providers.{name}.api_key |
| 端点 | {api_base}/messages |
| 多 provider | 支持,从 .config.json 读取多个 provider 配置 |
| 格式转换 | 接受 OpenAI 格式输入,内部转 Anthropic 格式,返回 OpenAI 格式 |
链路三:向量嵌入(scripts/memory/embedder.py)
| 项目 | 说明 |
|---|---|
| 用途 | 文本向量化(记忆存储、语义检索) |
| 函数 | embed_text() / embed_batch() |
| HTTP | requests 同步 |
| 接口 | OpenAI 兼容 /embeddings(与对话 API 完全不同) |
| 认证 | .config.json 中 llm_providers.embedding.api_key |
| 模型 | text-embedding-v3(通义千问),1024 维 |
| 缓存 | LRU 内存缓存(最多 1000 条) |
现状问题
- 三条链路各自为政:
direct/client.py、llm/client.py、embedder.py的请求头、错误处理、provider 配置各写一套 - 多 provider 能力不统一:
llm/client.py支持多 provider,direct/client.py不支持,embedder.py单独配置 - 改一个另一个不跟:Anthropic API 更新时需要改多个地方
- 残留依赖:
pyproject.toml里还有claude-agent-sdk,代码已不使用
三、统一方案
目标
统一入口:不管以后接多少个模型,所有调用都通过同一个 LLMClient 类进入。对话、内部调用、向量嵌入,全部是 LLMClient(provider=xxx) 实例化后调对应方法。provider 配置统一写在 .config.json 的 llm_providers,加新模型只加配置,不改代码。
所有调用方
↓
LLMClient(provider=...) ← 唯一入口
├── agent_loop() → 主对话(async + SSE)
├── sync_call() → 内部调用(sync,非流式)
└── embed() / embed_batch() → 向量嵌入(sync,/embeddings 端点)类名:LLMClient
放在 scripts/llm/client.py。scripts/direct/client.py 改为薄包装,re-export LLMClient as DirectClaudeClient。
配置优先级
构造函数参数 > 环境变量 > .config.json provider 配置LLMClient()(无 provider)→ 读环境变量ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN,用于主对话LLMClient(provider="dashscope")→ 从.config.json读,用于内部调用LLMClient(provider="embedding")→ 从.config.json读,调 embed 方法
Headers 按 provider 区分
provider 配置新增可选字段 "api_style": "claude" | "openai"(默认 "claude"):
"claude":完整 Claude Code 伪装头(stainless/beta 等)+SYSTEM_PROMPT_PREFIX"openai":标准 headers(Authorization: Bearer),不加伪装头和 system prefix
无 provider 时(环境变量模式)默认 "claude" 风格。
Sync/Async 共存
一个类,两套 HTTP 方法:
- Async(httpx):
agent_loop(),query(),ask(),count_tokens(),compress_messages() - Sync(requests):
sync_call()— 单次非流式调用
共享:_build_headers(), _build_system_blocks(), _build_body()
Token 计费
TokenUsage.calc_cost() 继续用于 SDKChunk.cost 展示。llm_usage.record_usage() 已移除(不再写计费 DB)。
LLMClient 类设计
class LLMClient:
def __init__(self, model=None, max_tokens=None, max_turns=None,
base_url=None, auth_token=None, provider=None):
if provider:
config = load_config(provider)
self.base_url = config['api_base']
self._auth_token = config['api_key']
self.model = model or config['model']
self._api_style = config.get('api_style', 'claude')
self._proxy = config.get('proxy')
self._timeout = config.get('timeout', 30)
else:
self.base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL", ...)
self._auth_token = auth_token or os.environ.get("ANTHROPIC_AUTH_TOKEN", ...)
self.model = model or DEFAULT_MODEL
self._api_style = "claude"
...
# --- 共享基础设施 ---
def _build_headers(self): ...
def _build_system_blocks(self, system): ...
def _build_body(self, messages, system_blocks, tool_params, stream=True): ...
# --- Async 方法(搬自 DirectClaudeClient,改动极小) ---
async def agent_loop(...): ...
async def query(...): ...
async def ask(...): ...
async def count_tokens(...): ...
async def compress_messages(...): ...
# --- Sync 方法(基于现有 call_llm 逻辑) ---
def sync_call(self, messages, system=None, tools=None,
temperature=0.3, max_tokens=4096) -> dict: ...
# --- 向量嵌入:Sync,复用认证和配置,走 /embeddings 端点 ---
def _build_embed_headers(self) -> dict: ... # 始终用 Authorization: Bearer
def embed(self, text: str, dimensions=1024) -> list[float]: ...
def embed_batch(self, texts: list[str], dimensions=1024) -> list[list[float]]: ...兼容函数
调用方零改动,内部代理到 LLMClient:
# llm/client.py
def call_llm(messages, tools=None, temperature=0.3, provider=None) -> dict | None:
client = LLMClient(provider=provider or _get_default_provider())
system, anthropic_msgs = _openai_to_anthropic(messages)
data = client.sync_call(anthropic_msgs, system=system, ...)
return _anthropic_to_openai(data)
# memory/embedder.py — 单例客户端 + LRU 缓存
_embed_client: LLMClient | None = None
def _get_embed_client() -> LLMClient:
"""懒加载单例,model 变更后自动重建(感知配置热重载)。"""
global _embed_client, _embed_client_model
cfg = load_config("embedding")
if _embed_client is None or cfg["model"] != _embed_client_model:
_embed_client = LLMClient(provider="embedding")
_embed_client_model = cfg["model"]
return _embed_client
def embed_text(text: str) -> list[float]: # LRU 缓存 → _get_embed_client().embed()
def embed_batch(texts: list[str]) -> list[list[float]]: # 命中缓存跳过,未命中批量一次调用上层接口保持兼容
LiveSessionManager/DirectSessionManager的接口不变call_llm()的接口不变embed_text()/embed_batch()的接口不变(内部改为代理LLMClient)- 只是底层实现统一
四、模型切换
对话模型:随时可切
切换方式:修改 .config.json 中 provider 的 model 和 api_base,配置热重载,不用重启。
前提条件:
- 目标模型支持 Anthropic Messages API 格式(或中转站做了格式转换)
- 支持 tool_use(agent loop 需要)
- 支持 streaming SSE
不同场景可以用不同模型:
- 主对话用大模型(如 claude-sonnet-4-6)
- 内部打分/摘要用小模型(更便宜更快)
向量模型:需谨慎
切换前提:
- 新模型支持 OpenAI 兼容
/embeddings接口 - 维度对齐:新模型输出的向量维度需与数据库中已有数据一致(当前 1024 维)
重要:换向量模型意味着旧向量全部作废,需要全量重新 embed。因为不同模型的向量空间不同,混用会导致相似度计算无意义。
建议的切换流程:
- 新模型产出 embed,写入新 collection
- 验证检索质量
- 切换读取指向新 collection
- 清理旧数据
五、配置示例
.config.json 新增 api_style 字段(可选,默认 "claude"):
{
"llm_providers": {
"default": {
"api_base": "http://127.0.0.1:18080/v1",
"api_key": "your_key",
"model": "claude-sonnet-4-6",
"api_style": "claude",
"timeout": 30,
"proxy": null
},
"dashscope": {
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "sk-xxx",
"model": "qwen-plus",
"api_style": "openai",
"timeout": 30
},
"embedding": {
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "sk-xxx",
"model": "text-embedding-v3",
"api_style": "openai",
"timeout": 30
}
}
}六、文件变更
1. scripts/llm/client.py — 重写(核心)
从 direct/client.py 搬入:
- 常量:
CLAUDE_CODE_HEADERS,SYSTEM_PROMPT_PREFIX,DEFAULT_MODEL/MAX_TOKENS/MAX_TURNS,WEB_SEARCH_TOOL - 数据类:
HookSet,SDKChunk,ToolDef,TokenUsage - 工具函数:
_iter_sse(),_ulog_tool()
新增 LLMClient 类(合并 DirectClaudeClient 的 async 方法 + call_llm 的 sync 方法 + embed 嵌入方法)。
保留 call_llm() 为兼容函数,保留 load_config(), get_provider_names(), reload_config(), _openai_to_anthropic() 等辅助函数。
2. scripts/direct/client.py — 改为薄包装
from scripts.llm.client import (
LLMClient as DirectClaudeClient,
HookSet, SDKChunk, ToolDef, TokenUsage,
DEFAULT_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_MAX_TURNS,
SYSTEM_PROMPT_PREFIX, CLAUDE_CODE_HEADERS, WEB_SEARCH_TOOL,
)所有现有 from scripts.direct.client import ... 和 from scripts.direct import ... 路径不变。
3. scripts/llm/__init__.py — 新增导出 LLMClient
4. scripts/memory/embedder.py — 改为代理 LLMClient
移除:独立 HTTP 逻辑(_get_embed_session、_get_embed_config、直接 requests.post)。
改为:
- 从
scripts.llm.client导入LLMClient、MAX_EMBED_CHARS、load_config _get_embed_client()懒加载单例;检测到 config 中model变更时自动重建embed_text()/embed_batch()保持原有签名,内部代理到LLMClient.embed()/LLMClient.embed_batch()embed_batch()收集所有缓存未命中项后一次 API 调用批量获取("input": [list]),按index字段排序结果
不需要改动的文件
| 文件 | 原因 |
|---|---|
scripts/direct/__init__.py | from .client import DirectClaudeClient 自动从薄包装获取 |
scripts/direct/session.py | 懒导入 from .client import DirectClaudeClient,类名不变 |
scripts/tg_claude_sdk.py | from scripts.direct import DirectClaudeClient,不受影响 |
scripts/memory/searcher.py | 通过 call_llm() 调用,接口不变 |
scripts/memory/session_brief.py | 通过 call_llm() 调用,接口不变 |
scripts/direct/hooks.py | 不直接引用 client |
scripts/direct/tools/task.py | from scripts.direct.client import DirectClaudeClient,别名保持 |
七、执行顺序与验证
执行顺序
scripts/llm/client.py— 重写:搬入常量/数据类 + 实现 LLMClient + 保留 call_llm 兼容 + 新增 embed 方法scripts/direct/client.py— 替换为薄包装scripts/llm/__init__.py— 更新导出scripts/memory/embedder.py— 改为代理 LLMClient,MAX_EMBED_CHARS从llm/client导入
验证(2026-03-12 实测)
- 同步链路:
python -c "from scripts.llm import call_llm; ..."✓ - 异步链路:
python scripts/direct/client.py(现有 _test 函数)✓ - 导入兼容:
python -c "from scripts.direct import DirectClaudeClient, SDKChunk, ToolDef"✓ - session 兼容:
python -c "from scripts.direct import DirectSessionManager"✓ - lint:
ruff check scripts/llm/client.py scripts/direct/client.py scripts/memory/embedder.py✓ - 向量链路:
python -c "from scripts.memory.embedder import embed_text, embed_batch"✓ - 单元测试:7/7 通过(导入、call_llm 兼容、LLMClient 实例化、embed 方法、embed_batch、薄包装别名、embedder 代理)
八、风险点
| 风险 | 应对 |
|---|---|
| 循环导入 | LLMClient 无外部内部模块依赖,原 llm_usage.record_usage() 调用已移除 |
| httpx 依赖 | scripts/llm/client.py 新增 import httpx(顶层),requests 保持懒导入 |
| proxy 配置差异 | 无 provider 模式从 .config.json 的 telegram.proxy 读取(沿用现有逻辑) |
_build_body stream 参数 | 从硬编码 True 改为参数化,sync_call 传 stream=False |
| embedder 单例感知配置热重载 | _get_embed_client() 每次调用检测 config model 字段变更,变更时自动重建客户端 |
九、待实现
- [x] 实现 LLMClient 统一类(合并
direct/client.py和llm/client.py) - [x]
direct/client.py改为薄包装 - [x]
llm/__init__.py更新导出 - [x]
memory/embedder.py改为代理 LLMClient(embed 链路统一入口) - [x] 清理
pyproject.toml中的claude-agent-sdk残留依赖 — 已移除,现为claude-code-sdk - [ ] 向量模型切换流程(全量重 embed + 验证)
十、记忆与知识系统
定义
PersonContext、Memory、Knowledge、Daily Notes、context providers 各自的边界与协作方式。 Memory 是长期辅助认知,不是人物、事实、议程、Concern 的运行态真源。
10.1 系统分工
当前与信息沉淀相关的能力分为五层:
| 层 | 作用 |
|---|---|
PersonContext | 当前人物工作视图 |
Memory | 历史事实、经验、规则沉淀 |
Knowledge | 文档、制度、说明、长期资料 |
Daily Notes | 每日原始工作流水 |
context_providers | 实时业务快照 |
其中,Agenda / Concern 属于运行态真源,不属于记忆系统。
10.2 边界说明
PersonContext
回答:
- 这个人是谁
- 现在在做什么
- 当前卡在哪
- 应该怎么和他沟通
Memory
回答:
- 过去发生过什么
- 某次承诺是什么
- 某条经验和规则是什么
Knowledge
回答:
- 文档怎么写
- 制度和定义是什么
- 公司长期资料有哪些
Daily Notes
回答:
- 今天发生了什么
- 系统今天做了什么
context_providers
回答:
- 当前实时业务状态是什么
10.3 当前记忆写入方式
对话侧当前主要写入两类记忆:
chat- 对话提炼后的内容
- scope 一般按当前会话隔离
rule- 审批经验
- 承诺 / 规则 / 高价值结论
- 通常进入
company级别可复用范围
人物工作状态变化则直接写入 person_work_state,不通过记忆主库兜底。
议程变化写入 Agenda / Concern,也不通过 Memory 兜底。
10.4 检索策略
当前对话采用按需检索:
- prompt 不整包预塞大量记忆
- 模型需要时调用
MemorySearch session_brief负责承接短期会话状态
这使记忆更像"可调用的历史库",而不是固定附在每次对话上的长附件。
10.5 当前原则
- 人物当前真相在
PersonContext - 历史事实与规则在 Memory
- 长期资料在 Knowledge
- 每日流水在 Daily Notes
- 实时业务状态在 context providers
- 议程与事项运行态在
Agenda / Concern
它们互补协作,但不互相替代。
十一、技能系统
记录清海如何以"轻提示 + 按需展开"的方式使用技能与工具。
11.1 核心思路
技能系统采用渐进式信息披露:
- system prompt 中只放技能目录摘要
- 模型需要时再读取对应
SKILL.md - 真正执行时再调用工具
这样可以保持 prompt 干净,同时允许技能持续扩展。
11.2 三层结构
第一层:技能目录摘要
第二层:读取某个 SKILL.md
第三层:调用真实工具第一层
当前通过 discover_skills() 把技能名称和路径注入 prompt。
第二层
模型自己读取某个 SKILL.md,理解使用方法、限制与最佳实践。
第三层
模型调用真实工具完成动作。
11.3 常驻工具与技能的边界
常驻工具
适合 schema 自解释、无需额外长文说明的能力:
MemorySearch- 渠道业务工具
- concern 工具
update_work_status
技能
适合需要更长说明文档的能力:
- 复杂外部系统
- 多步骤操作规范
- 某类特定业务 SOP
11.4 不会作为技能的能力
以下能力由系统自动提供,不通过 SKILL.md 暴露:
| 能力 | 进入方式 |
|---|---|
session_brief | 对话自动注入 |
person context slice | 对话自动注入 |
| concern 摘要 | 对话自动注入 |
| context providers | 对话按需注入 |
这些是对话运行时底座,不是"可选技能"。
11.5 设计目标
技能系统的目标只有两个:
- 让模型知道自己还有哪些延伸能力
- 不让这些说明文档持续占用主 prompt 预算
当前实现已经围绕这两个目标展开。