本文件是给在本仓库里干活的人和 AI 助手看的硬约束。改代码前先读这里; 与这里冲突的改动,要么别做,要么先改这里并说明理由。
2.0 是通风智能体系统的新一代骨架,参照 1.5 的对外契约重建,但内部实现 不做移植。定位是「可运行的骨架」,不是「功能完备的产品」:
POST /api/chat 的 multipart 入参与 SSE 帧格式),
这样 1.5 的前端不改一行就能连上。thread_id ≡ session_id由 api/chat_routes._init_session 强制。客户端传别的值一律 404。
理由:checkpointer 是进程级共享的,允许客户端指定任意 thread_id 就等于 允许读写别人的图状态。这条不变式在 2.0 是全局的——包括定时任务 (它为自己的每次执行建独立会话,而不是另起一个 thread_id 命名法)。
workers=1)三处进程内状态:SQLite checkpointer、并发闸计数、APScheduler。 多 worker 下它们各自独立,症状是「同一会话续跑读不到上文的图状态」、 「并发闸形同虚设」、「定时任务被每个 worker 各触发一次」。
main._check_single_process() 会检测并告警。要扩多进程,先把这三处
换成 Redis/MySQL,别只改启动参数。
api/context_meter.extract_usage 优先读 usage_metadata,读不到才回落本地
估算,并把 usage_source 标成 estimate。
不要改成本地估算优先。1.5 就是估算优先,为此专门开了
fix/context-usage-accounting 分支修口径;估算值参与决策的代价是
「限额拦不住、账单对不上」,而且没人能一眼看出哪些数字是估的。
工具用 tools.registry.sourced(data=..., sources=[...]) 返回结构化的来源;
api/source_tracker 从 ToolMessage 里抽取并去重。
不要在提示词里要求模型「记得写引用」。模型漏写和编造这两种失败模式 无法在事后可靠区分;读工具返回值就不存在这两个问题。
deepagents 的 SummarizationMiddleware 会把被淘汰的历史追加落盘到
/conversation_history/{thread_id}.md,并把路径嵌进摘要正文,agent 之后
能自己 read_file 找回来。
两个后果要记住:
create_deep_agent。
create_agent 会校验中间件 name 唯一,重复直接抛
"Please remove duplicate middleware instances"(1.5 时代靠同名替换的
做法在 deepagents 0.6.x 已失效)。CTX_COMPACT_ENABLED=0(内部走
HarnessProfile.excluded_middleware)。关掉会同时失去历史落盘能力,
长会话会硬撑到超窗报错。这些都在开发过程中真实触发过,症状与原因距离很远,所以单独记下来。
.env机器上存在全局 OPENAI_API_KEY 时,load_dotenv(override=False) 会让
进程环境胜出、项目 .env 被静默忽略。症状是「密钥明明配对了却报 401,
且报错里显示的密钥后几位和 .env 里的不一样」。
config.py 的对策:项目 .env 优先(override=True),且冲突时打
WARNING 并把冲突键暴露给 /health 的 config_conflicts。
MemorySaver 之外,checkpointer 必须显式声明上下文窗口摘要中间件的 trigger=("fraction", x) 依赖模型 profile["max_input_tokens"]。
不声明的话,框架在首次触发压缩时报错而不是降级。
agents/model_factory.build_model 无条件设置 profile={"max_input_tokens": window}。
新增任何模型构造路径都要照做。
tools 必须是 BaseTool,主 agent 的 tools 可以是裸函数两者要求不同:主 agent 的 tools= 由框架包装,子智能体的 tools 会被直接
塞进 ToolNode,那里要读 tool_.name。传裸函数会炸在 tool_.name 上,
而堆栈只指向 SubAgentMiddleware,看不出是参数形态问题。
用 tools.registry.get_tools_by_name([...]),它返回 BaseTool 列表。
只实现 wrap_model_call 而服务全程走 astream 时,运行期抛
NotImplementedError,报错位置在模型节点内部。
agents/middleware.DynamicTimeMiddleware 同时实现了 wrap_model_call 与
awrap_model_call。
messages 通道stream_mode=["updates","messages"] 的 messages 通道里不只有
AIMessageChunk,ToolMessage 也在推。不过滤的话,工具返回的原始 JSON
会混进给用户看的正文。
对策:只在 isinstance(chunk, AIMessageChunk) 时发 token 事件。
同理,摘要中间件的模型调用也在同一通道,靠 metadata["lc_source"] ==
"summarization" 过滤。
astream({"messages": [...]})。传裸 list 会被当成 __start__ 节点的返回值,
报 InvalidUpdateError: Expected dict, got [...]。
load_builtin_tools 对已导入模块是空操作工具的注册靠模块体里的装饰器,而 import_module 对 sys.modules 里
已有的模块不重放模块体。所以「清空注册表后重新装载」必须显式 reload,
否则注册表一直是空的,表现为「工具莫名其妙全没了」。
created_at 只到秒时,同秒写入的 user/assistant 两条消息只能靠随机 UUID
做次级排序,顺序会随机颠倒——冷启动补种图状态时会把「谁说了什么」喂反。
db.db_conn.fmt_dt 输出到微秒,MySQL 侧对应 DATETIME(6)。
ContextMeter(算上下文占用与用量)与 billing.meter.UsageMeter(入账)
是分开的。收尾时必须显式把前者的数字 add 给后者——漏了这一步,
交互式对话全程不会计费,而 /usage/today 显示 0,表面看「一切正常」。
FilesystemPermission 是逐条匹配、命中即返回。所以具体的 allow 必须排在
宽泛的 deny 之前,否则 deny /** 会把后面的 allow 全部短路,agent
连技能文件都读不到。
python main.py 会让模块被导入两次uvicorn.run("main:app") 会再导入一次 main,于是模块级副作用执行两遍
(一次作为 __main__,一次作为 main)。日志会重复,配置会被构造两次。
副作用一律放进 lifespan,模块级只留纯定义。
main.py ──> api/ ──> agents/ ──> tools/
└──> billing/ ──> db/
└──> scheduler/
所有层 ──> obs/ 与 config
规则:
db/ 不反向依赖上层。业务逻辑不进 store,store 只做存取。billing/ 不依赖 api/。计费只接受标量参数,不认识 StreamRequest。tools/ 不依赖 agents/。工具是纯函数,拿不到 agent。obs/ 与 config 可以被任何层依赖,它们不依赖任何业务层。config,不要在业务代码里 os.getenv。api/sse_events.py 声明。其他任何地方都不得
手拼 data: 字符串——字段改名漏掉一处,前端就静默渲染失败。asyncio.to_thread,
否则单进程下一个人查历史会卡住所有人的流。# 1) 离线单元 + 契约测试(不需要模型密钥,不需要数据库服务)
python -m pytest
# 2) HTTP 层端到端(需要服务已启动 + 可用的模型密钥)
python main.py & # 或 bash scripts/start_server.sh start
python scripts/http_e2e.py
# 3) 只做导入与语法自检
python scripts/selfcheck.py
三者的分工:
| 检查 | 覆盖 | 何时跑 |
|---|---|---|
pytest |
单元逻辑 + SSE 契约字段 + 内核通道分工(用假 agent) | 每次改动 |
scripts/http_e2e.py |
真实 HTTP + 真实模型 + 落库 + 中断闭环 + 调度 | 提交前、发布前 |
scripts/selfcheck.py |
导入、建表、agent 编译、工具注册 | 改结构后快速自检 |
改 SSE 事件字段必须同步改 tests/test_sse_contract.py——那里的断言就是
兼容性契约本身。
列在这里是为了让读代码的人知道「这里没写不是因为忘了」。
/api/chat 直接取 multipart 的 user_name(缺省 default),
客户端可自报身份。计费、会话归属、并发配额全建立在这个值上,所以
当前只适合内网或开发环境。接真实鉴权时替换
api/chat_routes._resolve_user 为 Depends(...) 即可,其余代码不用动。tools/demo_tools.py 的模子加。tools/mcp_client_pool.py 等未移植。_ensure_session_title)。省一次模型
调用的钱和延迟;想换只改那一个函数体。remember 长期审批规则未实现。ResumeRequest.remember 收到即忽略。动到以下任一文件时,想一想上面哪条纪律相关:
| 文件 | 相关纪律 |
|---|---|
api/sse_events.py |
契约唯一出口(约定 §4)+ 契约测试必须同步 |
api/sse_core.py |
坑 5 / 坑 6 / 坑 9、不变式 1、3、4 |
agents/agent_factory.py |
不变式 5、坑 2、坑 3、坑 10 |
config.py |
坑 1、每项必须有默认值 |
agents/agent_memory.py |
不变式 2、坑 2 |
tools/registry.py |
不变式 4、坑 7 |
billing/* |
不变式 3、坑 9 |
db/* |
坑 8 |
main.py |
坑 11、不变式 2 |