本文件是给在本仓库里干活的人和 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(实测遇到过一把无效的)时,进程环境会盖掉
项目 .env。症状是「密钥明明配对了却报 401,且报错里显示的密钥后几位和
.env 里的不一样」。
config.py 把它做成显式可配的 DEEP_AGENT_ENV_PRIORITY:
dotenv(默认):.env 优先。防「被污染的 shell 静默改掉项目配置」process:进程环境优先。跑变体实例必须用这档,例如
DEEP_AGENT_ENV_PRIORITY=process DB_DIALECT=mysql PORT=8081 python main.pynone:完全忽略 .env(测试/CI 用)为什么默认不是 process:两种失败模式代价不对等。dotenv 的代价是「有意的
环境变量覆盖不生效」——加个前缀就绕过,且启动日志会告诉你;process 的代价是
「本机某个无关的全局变量静默改掉项目配置」——表现为身份诡异的 401 或连错库。
无论哪一档,冲突都会打 WARNING 并把键名暴露给 /health 的
config_conflicts,包括「生效值长什么样」(脱敏)。这条可见性是本机制的核心
价值:它把「配置没生效」从玄学变成一行日志。
注意 process 档会放行所有环境变量,包括那把无效的全局密钥——所以跑变体
实例时要连密钥一起显式传入,不要只传你要改的那几项。
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,模块级只留纯定义。
tool_call_id 索引,不能按工具名同一轮里同一个工具会被调多次(实测 agent 会自己换关键词连查 4 次)。
只按 tool 名索引卡片 → 后一次的结果挂到前一张卡上,界面看起来「结果和
参数对不上」。
1.5 的契约里没有 tool_call_id,2.0 补了它作为附加字段(sse_events
的 tool_call / tool_result)。附加字段安全、改名不安全——见 §四的契约纪律。
element.textContent += x 会摧毁子元素textContent 的读是「所有后代文本的拼接」、写是「替换全部子节点」。所以
bubble.textContent += token 这类写法会:① 把子元素(工具卡片)的文本一起
读进来,② 赋值时把那些子元素整个删掉。表现是工具卡片被抹成一坨纯文本,
而且每次追加都重新拼接全文(O(N²))。
正确做法是给正文一个独立的文本节点,并在插入卡片时「断开」当前文本段,
以保留时间顺序(见 static/index.html 的 appendToken / addCardFor /
breakSegment)。
pdfminer.six 默认按内容流顺序输出文本,表格页抽出来是「先把一列名称全列完、
再列数值列」。把这段文本交给模型提取,上百行记录的名称与数值会张冠李戴,而且
文本看起来是通的,不核对就发现不了。
algorithms/airflow_review/parsers/plan_preprocess.py::_read_pdf 因此按坐标重建
视觉行序:按纵坐标聚类、簇内按横坐标排序,同一行多列用制表符分隔。判据是
「预处理产出的表格行合计能否对上文档声称的小计」——布尔台计划两套回风系统分别是
9228 与 17853,重建后能精确对上;tests/test_airflow_tools.py::test_real_plan_preprocess_is_faithful
把这条断言固化了下来。换 PDF 解析库时必须重跑它。
hierarchy._component_ids 曾对「组成对象列表」做 unique(),结果「重复汇总」
这个要检的缺陷永远检测不出来——去重把证据删掉了。同类陷阱:预处理里若按
「连续重复行即噪声」去重,会删掉表格第三列的类型标签(「硐室」「其他巷道」连续
出现),三元组断裂,表格识别结果直接归零。去重必须带长度与白名单门槛。
布尔台计划写 Q采1=60×11.3×1.0×1.2×1.5=1221:公式文字写了 70% 有效断面系数,
代入的 11.3 却已经是「平均有效净断面积」。复算若一律按 60×0.7×v×S×…,每个采面
都会被判成计算错误。
规则库为此支持 variants,按参数口径事实(section_area_basis)选公式。
新增公式前先看真实计划怎么代入,不要只看公式文字。同理,规则表里「按什么字段
匹配」与「区间边界字段叫什么」往往不同名(max_temperature vs min_temp),
所以表定义里要显式写 bounds。
口径标记还有一层坑:模型会把口径写成同义说法(实测把"有效断面积"写成"平均有效
净断面积"),精确匹配不命中就退回基础表达式,于是把一份正确计划算成"与计划不符"、
整盘阻断。因此有三道处置:① RuleSet.parameter_markers() 把允许取值(含
marker_values 声明的完整词汇表)下发给提取模板,从源头约束写法;② 命中不到时用
计划自己写出的结果反推变体(_infer_variant),并标 variant_selection =
inferred_from_stated_calculation 提示人工核实;③ 自洽核验改为「在规则库允许的任一
口径下能复现计划写出的结果即算自洽」(_reproduce_with_candidates),一个都复现不
出来才是真算错。注意这里的候选集只来自规则库,不是任意表达式——所以"逐个试算"并没有
放宽判定。
工具是纯函数,tools/ 又不许依赖 agents/,可它必须把上传文件的虚拟路径
(/workspace/uploads/...)映射回真实沙箱路径。做法是用
langgraph.config.get_config() 取运行层写进 configurable 的 workspace_user /
workspace_session,再按同一套哈希规则算根目录
(extensions/vent_airflow/workspace_paths.py)。
重写这套映射的风险是两处漂移,因此 tests/test_airflow_tools.py::test_workspace_path_parity_with_agents_layer
断言它与 agents.workspace.session_root 一致——漂移会变成失败的测试,而不是线上
「找不到文件」。注意 get_config() 在运行上下文之外(脚本、测试)抛
RuntimeError,必须捕获并回落到项目内目录。
/workspace 是会话沙箱,不是仓库目录agent 看到的 /workspace/ 映射到 data/workspaces/<用户哈希>/<会话哈希>/workspace/
——每个会话一份、互相隔离,由 backend_for_runtime 按需创建。仓库根目录下没有、
也不该有同名目录:_ensure_dirs() 曾经创建它(历史遗留),实测后果是使用者把矿方资料
放进仓库的 workspace/矿井风量研判资料/,然后让 agent 研判,agent 的 ls /workspace
返回空、给工具传路径报「文件不存在」,接着在文件系统里连查二十来个路径(/、/data、
/tmp、/files、全盘 glob…)全部 permission denied。
别再把它加回来。 资料进 agent 的通道只有两条:
/workspace/uploads/。/api/chat 除 1.5 兼容的单份 file 外还有
files 多附件字段(上限 TFAGENTS_MAX_UPLOAD_FILES)——研判至少要「配风计划 +
测风报表」两份,所以多附件字段是必需的。/skills/ 只放技能手册,不是资料。另外技能文档里必须明确告诉模型这两条,并让它「找不到就请用户上传」;否则它会去翻文件系统。
一次研判可能产出 92 个计划对象、95 条测风记录、上百条问题。把这些 JSON 全部 内联进工具返回值会:① 顶爆模型上下文,② 前端工具卡被截断成 4000 字符后只剩 半截 JSON。
extensions/vent_airflow/tools.py 的约定是「大对象落盘 + 紧凑摘要」:完整结果写到
会话工作区 outputs/airflow/<时间戳>-<名字>.json,返回值只带摘要、统计、内联前
AIRFLOW_MAX_INLINE_ITEMS 条,以及下游工具要用的 *_path。工具之间的传参因此
是文件路径,参数解析走 _load_argument(同时接受 JSON 字符串与路径)。
摘要压缩中间件需要后端时按框架自己的写法构造 runtime:
config = cast("RunnableConfig", getattr(runtime, "config", {}))
而 ModelRequest.runtime 的类型是 langgraph.runtime.Runtime——它没有 config 字段
(实测字段为 context / store / stream_writer / heartbeat / previous / execution_info /
server_info / control),所以这里 config 恒为空字典,框架注释也明说空字典是合法取值。
backend_for_runtime 早先只判断「config 属性是否存在」,拿到 {} 就当有效配置,于是
直接抛 ValueError: 文件后端缺少服务端会话身份。症状与原因距离很远:长会话、或读了长
文档的子智能体,一触发摘要压缩就整轮失败,而不是压缩生效;短会话永远复现不了(压缩
没触发就没事)。真实触发场景是子智能体分块读 63 页配风计划 PDF 后压缩启动。
守则:
runtime.config → get_config()),都没有才算失败。
拿起一个空字典就判定"没有身份"是错的。tests/test_workspace_backend.py:用 ToolRuntime(config={}) 在 langgraph
运行上下文里复现这条路径,不需要真模型。改 agents/workspace.py 必跑。get_config() 的后端实例,
而不是退回编译期固定的实例。真实故障:模型把 required_effective_window 写成字符串 "2024-12-01 ~ 2024-12-31",
而 compare.py 里是 dict(target.get(...) or {}) —— dict("…") 抛
ValueError: dictionary update sequence element #0 has length 1,异常一路穿过
ToolNode 打掉整轮 SSE 流(不是工具卡里报个错,是整个对话结束)。
两条纪律:
contracts.as_mapping / as_window / mappings 收口,不要直接
dict(...)。as_window 还能从 2024年12月1日、2024/12/01、2024-12-01 9:00
这类写法里把窗口捞回来(parse_time 做了非 ISO 归一化与补零),捞不回来就留空、
由调用方按"时间无法对齐"转人工复核。计划校验器对无法解析的生效窗口要给
unparsable_effective_window 问题,而不是放行。_readable_errors 安全网(extensions/vent_airflow/tool_support.py):
任何未预期异常都返回 {ok: false, error, hint} 并记日志,而不是抛出去。AGENTS §四
的「工具失败返回可读原因」在框架里是硬要求——抛异常的代价是整轮对话。顺带记两条同源修正(都有测试守):
workplace 缺 location_type 时要用工作面策略
(0.75),早先回落到回风系统的 1.0,等于把工作面按 100% 判定。ALIGN_MANUAL(窗口缺失或解析不出)与 ALIGN_FAILED 一样
只能输出人工复核——不知道实测是什么时候的,比了也没意义。report_id 由 mine_id/period 拼成、两者都来自模型,
实测传过 2024-12(配风计划)/ 2026-05-28(实测),直接当文件名会因斜杠被当成路径
分隔符而写失败。落盘一律过 report._safe_filename,逻辑 ID 原样保留用于展示。main.py ──> api/ ──> agents/ ──> tools/
└──> billing/ ──> db/
└──> scheduler/
所有层 ──> obs/ 与 config
extensions/ ──> tools/ 与 config(注册业务能力)
extensions/ ──> algorithms/(工具薄封装调确定性引擎)
algorithms/ ──> 不导入 api / agents / extensions
规则:
db/ 不反向依赖上层。业务逻辑不进 store,store 只做存取。billing/ 不依赖 api/。计费只接受标量参数,不认识 StreamRequest。tools/ 不依赖 agents/。工具是纯函数,拿不到 agent。需要会话上下文时
走 langgraph.config.get_config()(见坑 17)。algorithms/ 不导入 api/、agents/、extensions/。确定性计算与解析放这里,
可以被脚本、HTTP 路由、扩展工具三处复用;Agent 侧由 extensions/ 注册工具调用它。extensions/ 只做注册与薄封装。解析、复算、阈值、证据、渲染都在
algorithms/ 里;扩展文件不许出现风量公式或阈值数字。obs/ 与 config 可以被任何层依赖,它们不依赖任何业务层。config,不要在业务代码里 os.getenv。api/sse_events.py 声明。其他任何地方都不得
手拼 data: 字符串——字段改名漏掉一处,前端就静默渲染失败。POST /api/chat/only(见 §六),那是一次经过评估的、
有记录的收缩——不是「删了也无所谓」的先例。
多附件字段 files 是这条纪律的正例:研判类任务一次要传「配风计划 + 测风报表」两份,
于是保留 1.5 的单份 file 不动、另加一个 files 数组字段,两者合并处理。旧前端
一个字段都不传也照常工作。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——那里的断言就是
兼容性契约本身。
风量研判另有三条专项回归,改到规则库、解析器或引擎时必须重跑:
| 断言 | 位置 | 守的是什么 |
|---|---|---|
| 真实报表读数与原始文件一致(总进风 35181、总回风 35713、主扇 22512.68 / 13716.14) | tests/test_airflow_tools.py::test_real_measurement_report_totals_match_source |
报表分块定位与页眉页脚识别 |
| 预处理产出的表格合计能对上计划声称的小计(9228 / 17853) | test_real_plan_preprocess_is_faithful |
视觉行序重建正确,名称与数值没错配 |
| 真实层级 fixture 的权威需风量精确为 11073.6 / 21423.6 / 32497.2 | test_verify_real_table9_fixture_is_exact |
汇总公式、备用系数与容差 |
真实资料 workspace/矿井风量研判资料/ 不入库,缺失时上述用例 skip;tests/fixtures/airflow/
下的合成基准与转录 fixture 始终可用。
列在这里是为了让读代码的人知道「这里没写不是因为忘了」。
/chat/only1.5 有 POST /api/chat/only,2.0 删掉了。如果你正想「为了兼容 1.5 把它加
回来」,先看这段。
它和 /chat 的差别只有计费流水上那个 kind 标签(chat vs chat_only),
行为完全相同。而那个标签在 1.5 和 2.0 里都没有任何消费方:没有按渠道聚合
的接口、没有按渠道拆的额度、没有渠道维度的报表。也就是说它看起来像渠道区分,
实际不承载信息。
这份重复在 1.5 有可查的代价:
docs/实施记录-B3-P3.md 的 R7-#24 / P1-11 ——
「/chat vs /chat/only 标题计费口径不一致」。两个本该一致的入口漂移了。tests/l4_auth_runtime.py 里的 L4-1b-c2(thread_id
越权)与 L4-8c(413 体积闸)存在的唯一理由,是在第二个入口上再证一遍同样
的阀门。tests/probe_b345_dedup_diff.py 的唯一职责是证明两者
行为相同。_run_chat_pipeline」——1.5 自己也认定它是重复,只是出于兼容留了端点、只删
了重复实现。/chat/only。要重新引入这类入口,正确的顺序是先把渠道差异做实(按渠道聚合用量、按渠道 拆额度、渠道维度报表),再引入入口;不要先立一个行为相同的端点,指望以后有人 去消费那个标签。
保留的另两个入口 —— /chat/resume、/chat/interrupt —— 不属于这类:它们承载
的是不同能力(中断恢复、挂起查询),不是同一能力的别名。
/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 收到即忽略。algorithms/airflow_review/ + extensions/vent_airflow/)outputs/airflow/ 下,供对话内引用与 agent 用 read_file 回读;下载能力待
前端需求明确后再加。要做多矿批量,参照 algorithms/characteristic_curve_recognition/batch.py
的模式另建任务队列,不要往对话链路里塞。extensions/vent_airflow/monitoring.py 只实现了本地
文件适配器与「无数据源 → 转人工复核」的降级路径;接真实系统时替换 get_source()。preprocess_plan_document 是确定性的
(清洗、行序重建、章节与表格识别、生成提取模板),但数值提取由模型按模板完成,
再经 validate_plan_extraction 校验。因此「关键字段提取准确率」跟模型能力相关;
要提高确定性就把提取模板继续细化(如把表格线索直接喂成待确认候选值)。vent-plan-review skill 里有,属另一条产品线。动到以下任一文件时,想一想上面哪条纪律相关:
| 文件 | 相关纪律 |
|---|---|
api/sse_events.py |
契约唯一出口(约定 §4)+ 契约测试必须同步 |
api/chat_routes.py |
契约只做加法(§4);附件通道与 file/files 合并见坑 18,测试在 tests/test_chat_uploads.py |
agents/agent_factory.py / agents/workspace.py |
坑 10(权限首条命中即返回)、坑 18(虚拟 /workspace 是会话沙箱;不要创建仓库级同名目录)、坑 20(后端工厂必须回退到运行上下文取会话身份,否则压缩一触发就崩) |
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 |
algorithms/airflow_review/rules/*.json |
坑 16(公式变体与 bounds);改公式必须同步重跑三条专项回归 |
algorithms/airflow_review/parsers/plan_preprocess.py |
坑 14、坑 15;改行序重建必须重跑 9228/17853 断言 |
algorithms/airflow_review/engine/* |
坑 15(待检容器不去重);改阈值/容差必须同步 tests/test_airflow_engine.py 的九类基准 |
extensions/vent_airflow/workspace_paths.py |
坑 17;路径映射必须与 agents.workspace 保持一致(有 parity 测试守) |
extensions/vent_airflow/tools.py |
坑 18(大对象落盘)、AGENTS §四「工具失败返回可读原因」 |
extensions/vent_airflow/extension.py |
扩展只做注册;工具名或参数变更要同步 skills/airflow-review/references/contracts/tools.md(有测试守一致性) |