--- name: LLM 识图审图 Agent —— 架构决策(已定稿) description: cad_mcp_frame + 自研 Agent 的架构决策与分阶段路线——混合式(流水线骨架+受控只读取证子循环)、基座 B 最小集、可信度三件套(确定性检查前置/结构化结论/handle 交叉校验)、标准化路线(先 stdio 桥) type: project --- # LLM 识图审图 Agent —— 架构决策(2026-10-13 定稿) ## 决策 1. **Agent 形态:混合式自研**(非纯 ReAct、非纯流水线):确定性流水线骨架 (规划 → 逐图框取证 → 审查 → 汇总)+ 审查阶段给 LLM 一个**受控只读取证工具子循环** (白名单 15 个只读工具,`add_*/delete_*/set_*/draw_*` 一律禁止)。 2. **审查依据**:清单驱动(`review_checklist.json`)+ LLM 自主判断。 3. **输出**:Markdown + JSON 报告 + 可选 CAD 图上批注回写(统一落 `AI_REVIEW` 层)。 4. **基座改造:只做「B 最小集」**——换行分帧(`\n` 为消息边界)、去掉单次 `recv(2048)` 限制、 `isError` 映射、`id` 支持 string/number + 通知不回包;**不做** `initialize`/`ping` 握手。 5. **标准化路线**:先自研 agent 跑通 → 再做 stdio↔TCP 桥 → 远期才考虑 Streamable HTTP。 (**2026-10-14 更新**:stdio 桥**已实现并验证通过**,见 `project_stdio_bridge.md`。 远期仅剩 Streamable HTTP。) **Why(关键结论,非显而易见)**: - 基座是**裸 TCP 自定义传输**。规范虽允许自定义传输,但标准客户端(Claude Desktop / CodeBuddy / MCP Inspector)**只实现 stdio 与 Streamable HTTP**——因此裸 TCP 无论怎么补 生命周期都**无法被标准客户端直连**。"能被标准客户端接入"只有两条路:**stdio 桥** (省力、CAD 侧零改动)或 **Streamable HTTP**(彻底、要在 CAD 内做 HTTP+SSE、最贵)。 - 自研 agent 直接连 TCP,传输是否"标准"**不影响**,故**标准化不是前置条件**。 - `mcp_server.cpp` 原来**单次 `recv` 仅 2048 字节**,请求超 2KB 会被截断成 `-32700`; 这是**服务端**限制、客户端绕不过去 → 必须先改基座。 - image 内容块 `{type:image,data,mimeType}` **本就是 MCP 标准**(`source:{type,mediaType}` 是 Anthropic Messages API 的格式,别混淆),无需改。 **How to apply**:新增审图工具仍走 `cad_mcp_plugins` + `mcp_config.json`;agent 侧工具从 `tools/list` **动态发现**,加工具零代码。日后要做标准客户端接入,**从 stdio 桥起步**。 ## 新增产物 - `dwg_review/`(Python 包):`config.py`(环境变量,无硬编码 Key)、`mcp_client.py` (唯一 TCP 封装:单飞锁 + 换行分帧 + 重试 + 兼容 `error`/`isError`)、`llm_client.py`、 `cad_tools.py`(动态工具表 + 只读白名单 `READ_ONLY_TOOLS`)、`checklist.py`、 `phases/{planning,evidence,review,report}.py`、`annotator.py`、`__main__.py`。 **取代**根目录散落的 `dwg_review_agent.py` / `agent_loop.py` 等重复脚本 (这 5 个遗留脚本已于 2026-10-14 删除,见 [环境与外部资源](reference_environment.md))。 - `tests/`(pytest 回归网):假 LLM + 假 MCP 基座的**离线快层**,`tests/fakes.py` 提供 `FakeLlmClient` / `FakeMcpClient` / `make_ctx`。锁定 `dwg_review` 既有行为。 **陷阱**:绝不构造真 `LlmClient`(无 key 抛错)、无参 `CadTools()` 或 `ReviewContext.create()` (构造即 `list_tools()` 触网);一律 `make_ctx()` 注入假件。 - `verify_mcp_frame.py`:基座改动验证脚本(分帧 / 大报文 / string id / 通知 / isError)。 ## 可信度三件套(2026-10-14 落地) 根因是**「LLM 从自由文本里回忆 handle」**:原 `review_frame` 输出无格式约束的自由文本, 再用**第二次 LLM 调用**(`extract_issues`)转 JSON,handle 靠回忆 → 批注错位或丢失。 改后:`review_frame` 直接输出受约束 JSON `{summary, issues[]}`(**删除**二次抽取) + `checks.run_deterministic_checks`(确定性,handle 来自工具/数据本身) → `findings.build_finding`(合并 + handle 交叉校验)→ `annotator`(只批注 verified 的 handle)。 - **确定性检查前置**(`dwg_review/checks.py`):`find_zero_length` / `find_duplicate_entities` (都传 `window`)+ 纯 Python 派生(空文字、未闭合多段线 `vertex_count>=3 and closed==false`)。 实机验证:四类全部检出。 - **结论结构化**:`checklist.build_system_prompt` 强约束只输出 `{summary, issues:[{category,severity,message,handle?}]}`;`review_frame` 仅在 「不再请求工具」的回合解析(中间工具回合的 content 永不解析);解析失败降级 `summary=原文, issues=[]`(信息不丢,只是不进批注)。实机验证 deepseek-flash 遵守契约。 - **证据交叉校验**(`findings.normalize_issue`):LLM 引用的 handle 不在已采集证据中时 **保留问题、剥掉 handle、`verified=False`**(永不批注);handle 比对统一 `upper()`。 - 开关(`config.py`,默认全开):`DETERMINISTIC_CHECKS` / `VALIDATE_HANDLES` / `CHECK_UNCLOSED_POLYLINES`。 - 结构:issue = `{frame,category,severity,message,handle,handles?,source,verified}`; findings 元素 = `{frame,sheet,summary,issues[],counts{deterministic,llm,unverified}}`。 ## 环境事实 - **Pillow 已安装**(12.3.0)。截图下采样策略(`dwg_review/llm_client.py: downsample_image_b64`): 视觉 token 成本由**像素尺寸**决定而非文件大小 → **只在长边超 `IMAGE_MAX_EDGE`(默认 1280)时才缩放**; 既不需要缩放、格式又没变时**原样返回不重编码**(避免给 CAD 线稿引入无谓压缩损失); 缩放后默认输出**无损 PNG**(`IMAGE_FORMAT=png`,适合线稿),需要更小体积可设 `IMAGE_FORMAT=jpeg`。 缺少 Pillow 时自动降级为原图直传。 - 视觉模型 `deepseek-flash`。 - **`find_zero_length` / `find_duplicate_entities` 的 `window` 参数:schema 未声明,但 C++ 侧 `parseCommonQuery` 实际支持**(2026-10-14 实机验证生效)。Python 侧直接传 `window` 即可, 不必改 JSON schema、不必重编 C++。注意 `find_duplicate_entities.limit` 语义是 **最大重复组数**(非条目数),返回 `{count, groups:[{reason,type,handles[]}]}`(非分页)。 - **`DEEPSEEK_API_KEY` 设在 Windows `Machine`(系统)级**。因新设的系统变量**不会传播到已启动 的进程**,`dwg_review/config.py` 的 `_read_env()` 在进程环境读不到时**回退读注册表** (`HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` → `HKCU\Environment`)。 否则 CodeBuddy 内的命令永远读不到新设的 Key。 - **AutoCAD 2020 自动加载 ARX(用户配置,不在仓库内)**:在 `bin/Debug-R230/` 下建 `acad.rx`(内容一行 `cad_mcp_frame.arx`),并把该目录加入 CAD 的**搜索路径与信任路径**。 这是 AutoCAD 官方 RX 自动加载机制,比 `acaddoc.lsp` + LISP 更直接。 - AutoCAD 2020 安装于 `C:/Program Files/Autodesk/AutoCAD 2020/`;用户配置目录 `C:/Users/gjm/AppData/Roaming/Autodesk/AutoCAD 2020/R23.1/chs/`(`acaddoc.lsp` 已用于 PDF2CAD,勿覆盖)。 - **改 `cad_mcp_plugins` 后重链接必须先关 CAD**:插件 DLL 被 CAD 进程持有(`LNK1168: cannot open ...dll for writing`)。模板 `cad-mcp-frame.json` 里的 `"hot_reload": true` **只是占位、代码未实现**, `mcp_Tools::clear()`(唯一 `FreeLibrary` 处)仅在 ARX 卸载时调用 → **没有热重载**。 流程:关 CAD → `cmake --build --preset R230 --config Debug` → 重启 `acad.exe`(`acad.rx` 自动加载) → 轮询 `127.0.0.1:8080` 监听。编译阶段可先行(语法错误会被提前暴露,只有链接会因占用失败)。 **用户已授权(2026-10-15):遇到 LNK1168 直接 `Stop-Process acad -Force` 即可,不必先问。** ## 基座健壮性:工具执行的异常安全(2026-10-14) **不变量:`cad_mcp_frame` 的工具执行运行在 AutoCAD 主线程的窗口消息处理链上 (`TcpServerLoop` → `PostMessage` → `WndProc` → `ExecuteTaskInMainThread` → `mcp_Tools::Call`), 任何异常都不得逃逸出 `WndProc`,且异常路径上不得遗留文档写锁。** **Why**:插件 `Execute` 在参数**类型**不符时会抛 `nlohmann::type_error`(如 `args.value("bulge", 0.5)` 收到字符串、`parsePoint` 对 `["a","b"]` 取 `get()`—— 它只查 `is_array()/size()>=2`,不查元素类型)。而 `dwg_review` 由 LLM 驱动、 **模型经常生成类型错误的参数** → 这是日常路径,不是"恶意客户端"场景。 原实现全程无 try/catch:异常逃逸出 `WndProc` 属未定义行为(表现为 CAD 崩溃), 且 `mcp_Tool_DllProxy::Execute` 的 `unlockDocument` 在异常路径被跳过 → 写锁泄漏。 **How to apply**:基座已统一兜住(`mcp_Tools::Call` try/catch → `-32603` → `result.isError=true`; `HandleToolsCall` 校验 `params`/`arguments` 必须为对象、`name` 必须为字符串,否则 `-32602`; `ExecuteTaskInMainThread` 兜底 try/catch + `!req.is_object()` → `-32600`; DLL 代理写锁改为 `ScopedDocumentWriteLock` RAII)。**插件作者无需自行 try/catch**, 但仍不要在异常路径上留下副作用。新增/改动工具后跑 `verify_mcp_fault_injection.py` 回归。 **同一根因的另一面:阻塞。** 工具响应由 **AutoCAD 主线程**发送 (`WndProc` → `ExecuteTaskInMainThread` → `SendAll`),所以发送路径也必须有时限—— `accept` 时同时设了 `SO_RCVTIMEO`/`SO_SNDTIMEO`(各 5s),否则慢客户端能把 CAD 的 UI 线程卡死。 分帧规则:`\n` 优先,其次「暂时无更多数据、且累积内容已是完整 JSON」(兼容不发 `\n` 的旧客户端); **不要再引入基于等待时长的分帧判定**(旧的 150ms select 探测会把分片间隔大的大报文误截断)。 回归脚本:`verify_mcp_tcp_hardening.py`。 ## 验证结论(2026-10-13 实测通过) - `verify_mcp_frame.py` 4/4 PASS(换行分帧 / >2KB 报文不截断 / string id 原样回显 / 通知不回包 / isError)。 - `verify_annotate.py` 9/9 PASS(批注工具无回归)。 - `dwg_review --list-tools` = 26 个工具;`--plan-only` 取证正常;全流程跑出报告。 - **受控取证子循环实测生效**:LLM 自主调用了白名单内的 `get_drawing_info`/`get_layers`/ `find_zero_length`/`find_duplicate_entities`/`get_dimensions` 做二次确认。 - 批注回写实测通过;注意 `add_leader_annotation` 的 `points` **必须 ≥2 个点**(首个为箭头)。 ## 验证结论(2026-10-14 补充) - `verify_mcp_fault_injection.py` **22/22 PASS**(畸形/类型错误请求:结构性错误回 `-32602/-32600/-32700`;工具内部类型异常被兜为 `result.isError=true`;反复注入 10 次后 CAD 存活、写锁未泄漏)。回归 `verify_mcp_frame` 4/4、`verify_stdio_bridge` 24/24、 `verify_annotate` 9/9、`dwg_review --check` 均正常。 ## Plant 3D 图纸实体语义与圈注(2026-10-14 实机确认) **这是 Plant 3D / AEC 自定义实体图,插件无法内省**——`get_entity_detail` 对它们返回 **空 `properties`**,`query_entities` 也只给 `bbox`/`layer`/`type`。类型语义: - `AECDBEWPIPE` = 管道(一根管子一个实体)。 - `AECDBEWFITTING` = 管件,**两种形态靠 bbox 长宽比区分**: - 近正方(aspect = min/max ≥ 0.5,约 250×250)= **弯头**; - 细长条(aspect < 0.5,约 20×220)= **法兰**。 - `AECDBEWEQUIPMENT` = 设备,本图中即**阀门**(蝶阀符号)。 **不变量(交叉校验用)**:法兰数 == 2 × 阀门数(阀门两侧各一法兰)。 **How to apply(识别/统计这类图)**:不要依赖属性,改用「`query_entities(type_filter=...)` 取 bbox + 几何分类 + 不变量交叉校验 + `get_viewport_screenshot` 视觉复核」。图框用 `detect_drawing_frames`(本图返回 3 个块图框 A0/A1/A2,`kind=block`);把实体按「中心点落在 哪个图框 bbox 内」归类到框。 **圈注配方(已验证)**:`draw_revision_cloud {shape:"circle", center:[cx,cy], radius:max_side*1.1, layer:"AI_REVIEW", color:3}`——center 取阀门 bbox 中心,半径取包围盒 **长边 × 1.1**(本图阀门 bbox ≈230×220 → r≈253),绿色云线正好套住蝶阀符号。逐框 `zoom_to_window(frame_bbox)` + 截图可验证。画前若要重来,`delete_entities` 按 handle 删旧圆。