Files

165 lines
12 KiB
Markdown
Raw Permalink Normal View History

---
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<double>()`——
它只查 `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 删旧圆。