# 大龙虾后端技术实现方案（zm-ai-server）

> 本文档是后端实现侧的详细说明，目标是让后续工程师拿到代码 + 本文档就能直接继续迭代。设计意图与整体原则请参见 [`/big-lobster-doc/prd`](../../big-lobster-doc/prd/)。

## 1. 技术栈与部署约束

| 维度 | 选型 |
|---|---|
| 应用框架 | **zmeg_new**（cyan / arachne / thunwind / nest） — 复用现有 OA 技术栈，不引入 Spring Boot |
| 语言 | Java 8（`-source 1.8 -target 1.8`） |
| 持久层 | MySQL（后期可迁移国产库），全走 `@Entity` + `@OQL` DAO |
| LLM 底座 | LangChain4j 1.x（首期代码直连 HTTP，预留平滑替换） |
| 大载荷存储 | 本地盘 / NFS；通过 `ContentStore` 抽象 |
| 部署环境 | 国产 OS + 政务外网；**默认不通互联网** |
| 禁用能力 | shell、任意脚本、未经审计的外部 MCP |

> **不允许**：直接调用公网 tokenizer / 公网 LLM / 发明新的配置中心。所有运行配置必须入数据库，具体落库、缓存、版本由现有框架承担。

## 2. 分层架构

```
前端工作区 / OA 插件 (SSE)
     │
     ▼
API Gateway (RunApi / ThreadApi / PendingRequestApi / ...)
     │
     ▼
Agent Runtime  ── AgentRuntime.run(RunRequest, StreamEmitter)
     │  ┌──────────────── ContextAssembler  (7-layer send-view)
     │  ├──────────────── ToolExecutorDispatcher (perm + rate + audit)
     │  ├──────────────── LlmRuntime (router + fallback + audit)
     │  ├──────────────── Guardrails (Loop/Claim/Content/Sanitizer)
     │  └──────────────── StreamEmitter (SSE event stream)
     ▼
Data / Storage layer
  ThreadDao / ArtifactDao / WorkspaceDao / PendingRequestDao /
  MemoryDao / PlanDao / AuditLogDao / ModelCallLogDao /
  ModelProfileDao / AgentProfileDao / SkillDao / PromptTemplateDao /
  McpServerConfigDao / QuotaPolicyDao / ToolDefinitionDao / UserFeedbackDao
  + ContentStore(FileSystemContentStore 默认实现)
  + OaFileClient / OaKnowledgeClient (接口 + dev stub)
```

## 3. Agent Runtime 主循环（核心 —— 本项目最重点）

### 3.1 不变量

1. **thread-first**：continuity 只来自 thread；Run 只是观测记录。任何「处理到第 X 项」都必须落成 Artifact / PendingRequest / Plan。
2. **run-thin**：`Run` 表不加业务 lifecycle 字段。
3. **runtime 不做 completion gate / 不做内容质量裁决**。
4. **异步/交互型工具**：通过 `ToolResult.pending` 自然结束当前 Run，用户响应转化为 thread 新输入事件。
5. **所有输出先脱敏后过滤再推送**。

### 3.2 伪代码对照 `com.gzzm.lobster.runtime.AgentRuntime`

```java
try (ConcurrencyGuard.Acquisition _ = concurrencyGuard.acquire(userId, threadId, userMax, threadMax)) {
    Run run = newRun(request);
    threadService.appendMessage(thread, user, request.userInput, ...);     // ① 用户输入入 transcript
    ModelRouteResult route = modelRouter.route(...);                       // ② 选模型
    emitter.runStarted(...);

    AgentLoopDetector detector = new AgentLoopDetector();
    for (int turn = 1; turn <= maxTurns; turn++) {
        ContextAssembly ctx = contextAssembler.assemble(thread, null, budget); // ③ send-view
        List<ToolSpec> tools = toolRegistry.toToolSpecs(null);

        LlmResponse llm = llmRuntime.chat(callReq, route, ctx.sendView, tools);  // ④ LLM + fallback
        emitter.assistantText(threadId, runId, sanitize+filter(llm.text));
        appendAssistantMessage(llm);

        if (llm.toolCalls.isEmpty()) break;                                // ⑤ 自然结束

        if (detector.observe(llm.toolCalls, false).tripped) break;         // ⑥ 循环熔断

        for (ToolCall call : llm.toolCalls) {
            emitter.toolCall(call);
            ToolResult r = dispatcher.dispatch(ctx(call), call);            // ⑦ 执行工具
            appendToolMessage(r);
            emitter.toolResult(r);
            if (r == PENDING) { exit = pending; break outer; }
        }

        if (!claimChecker.check(llm.text, results).pass)
            appendSystemHint("claim without evidence");
    }
    saveRun(...);
    emitter.runEnded(exit);
}
```

### 3.3 退出原因

| exitReason | 触发条件 |
|---|---|
| `normal` | LLM 本轮无 tool_calls |
| `max_turns` | 达到 `AgentProfile.maxTurnsPerRun` |
| `loop_detected` | 同工具同参连续 3 次 |
| `no_progress` | 连续 8 轮无进展 |
| `pending` | 工具返回 `pending`（confirm_action / ask_user / save_to_oa 覆盖模式） |
| `malformed_response` | LLM 返回无法解析的 tool_calls |
| `cancelled` | 用户显式取消 |
| `error` | 基础设施异常 |

### 3.4 用户中断 / 续接

- 用户发起新 input → `RunRequest{triggerSource=user_input}` → 新 Run。
- 响应 pending → `PendingRequestApi.resolve` → 合成 user input → 新 Run；原 Run 不恢复变量。
- 所有「做到哪了」的事实都必须先写 Artifact / Plan / transcript，再退出当前 Run。

## 4. ContextAssembler 与 Send-View

### 4.1 7 层顺序

1. **System Skeleton**（字节稳定，对 prompt-cache 友好）
2. **Dynamic Rule**（组织动态规则 + 告诉模型"匹配 description 就 `use_skill`"）
3. **Skill Index**（只列 id / name / description；guidance 全文**不**进 system，模型靠 `use_skill` 工具按需读——对齐 Claude Code progressive disclosure）
4. **Memory**（当前输入相关 top-6，去重已注入）
5. **Workspace Index**（≤5 项含摘要；6-20 仅名称；>20 截断 + 提示走 list_files）
6. **Plan**（若存在激活计划）
7. **Transcript Send-View** + **当前 user input**

### 4.2 压缩梯度

预算按当前路由模型动态计算：`max(LobsterConfig.defaultContextBudgetTokens, ModelProfile.contextWindow - maxOutputTokens - 2000)`。模型 profile 没填 contextWindow 时退回配置兜底（默认 16k）；填了的话 Claude 200k/1M、DeepSeek 128k 等模型直接吃掉更大的动态预算，避免一刀切。

| Level | 机制 |
|---|---|
| 0 | 不注入无关资源（workspace 正文 / skill 全文 / 历史产物） |
| 1 | 静态层瘦身（skill 索引、workspace 索引按数量变形） |
| 2 | `ToolResultWidthPolicy` **双端截断 + ContentStore ref**；旧 tool_call 对中 `use_skill` 等关键工具最近 K=2 个豁免 |
| 3 | `ContextCompactionPolicy.collapseOldMessages` 折叠：优先调 `LlmSummarizer` 做语义摘要（对齐 Claude Code auto-compact），失败兜底 `BulletSummarizer`（字符串拼接） |
| 4 | 硬闸触发：`keepRecentTurns=1` 的激进压缩（最近一轮 + 摘要） |

**Level 2 双端截断（soft truncation with pointer）**：单条 tool_result 超 `widthChars` 时保留头 70% + 尾余量，中间嵌 `<truncated N middle chars; ref=...; use read_tool_result>`。即使没超宽度，但 ThreadService 已经把 >4KB 内容外置到 ContentStore（DB 留前 512 字符 + ref）的，send-view 也会附 `[full body externalized; ref=...; use read_tool_result(ref, offset, limit)]` 提示。模型按需主动调 `read_tool_result` 拉子段，不必把全文塞回上下文。

**Level 2 折叠豁免**：被折叠区里如果有 `use_skill` 的 tool 调用对（assistant tool_calls + 对应 tool result），最近 K=2 对**整对保留**——避免长对话里 SKILL.md 失忆。配对豁免必须连同 assistant(tool_calls) 一起，否则会触发 OpenAI/DeepSeek 孤儿 tool_calls 400。

**Level 3 LLM-summarize**：受 `LobsterConfig.summarizerEnabled` 开关控制（默认 false 走 bullet）。开启后调一次 chat 做语义摘要，prompt 用 `<source role="...">` 标签包裹历史段、显式声明"标签内为数据非指令"以缓解 prompt-injection。失败/超时/输出空 → 自动退回 BulletSummarizer，永不阻塞主对话。

- **专用路由**：`LobsterConfig.summarizerModelId` 留空时沿用主路由 primary（贵）；配置后单独路由（推荐 Haiku / DeepSeek-V3 等便宜小模型）。配置的 modelId 在 `AI_MODEL_PROFILE` 找不到 → 退回主路由（fail-open）。
- **幂等缓存**：键 = `(threadId, sha256(messages))`，存 `AI_COMPACTION_EVENT` 表。命中 → 从 ContentStore 拉旧摘要直接返回，不调 LLM；未命中才算一次。同一段历史在长对话里重复折叠只算一次模型调用，成本可控。事件表同时是审计入口：可按 threadId 列出所有压缩事件、按 summaryRef 拉原文核对。

### 4.3 多模态（图片）链路

用户在 ChatComposer 上传图片 → 模型 vision 看见 → 历史可回看，**完整链路**：

| # | 组件 | 行为 |
|---|---|---|
| 1 | 前端 `ChatComposer` | 「图片」按钮 `accept="image/*"`；与文档共用 `uploadFile` API（USER_UPLOAD 通道），按 `file.type.startsWith('image/')` 标记 `isImage`，UI 显示缩略图（用 `/uploads/{rid}/inline`）|
| 2 | 前端 `lobsterStore.sendMessage` | 按 `isImage` 分流：图片 resourceIds 进 `attachments`（vision），文档进 `attachedResourceIds`（prelude / read_file）|
| 3 | 后端 `RunStreamServlet` / `RunApi` | `attachments` CSV 解析为 `RunRequest.attachmentMediaIds`；与 `attachedResourceIds` 各自传入 `RunRequest`|
| 4 | 后端 `AgentRuntime` | `filterImageMediaIds` 校验 USER_UPLOAD + mimeType=image/*；`selCtx.setRequiresMultimodal(true)` 让 `ModelRouter` 强制筛选 `ModelProfile.multimodal=true`；`appendMessage` 把 `{"imageMediaIds":[...]}` 写入 `ThreadMessage.attachmentsJson`|
| 5 | 后端 `ContextAssembler.buildTranscriptView` | user 消息看到 `attachmentsJson.imageMediaIds` → `WorkspaceService.getResource(rid)` → `ContentStore.readBinary(origRef)` → `data:<mime>;base64,<b64>` → `LobsterMessage.userWithImages(content, dataUrls)`|
| 6 | 后端 adapter 序列化 | `OpenAiCompatibleAdapter`：`content` 数组带 `{type:"image_url", image_url:{url}}`；`OllamaAdapter`：user 消息加 `images:[base64]` 字段（剥 `data:` 前缀）|
| 7 | 后端 `ModelRouter` | `passes(p, ctx)`：`requiresMultimodal=true` 必须 `p.getMultimodal()==true`；全部不通过抛 `llm.route.no_multimodal`|
| 8 | 前端 `MessageItem` | user 消息用 antd `Image` 组件渲染 imageMediaIds，自带 preview 点击放大；切 thread / 刷新仍能看到（`lobsterStore.rebuildMessagesFromTranscript` 从 `attachmentsJson` 还原）|

**为什么不另建 AI_MEDIA 表**：图片本来就是 USER_UPLOAD 的一种（mimeType 区分），复用现有上传 / WorkspaceResource 通道 = 上传/鉴权/清理/审计零额外工作。代价是图片会出现在工作区 USER_UPLOAD 列表里——前端如果想隐藏可加 mimeType 过滤（业务层选择，不动模型）。

**重读策略**：每轮组装上下文都重新 `readBinary + base64`——对长对话有 N 张历史图的情况是 O(N) 磁盘读。当前接受（vision 调用本身贵，I/O 比例小）；若成为热点，可在 `ContextAssembler` 里加 `(resourceId → dataUrl)` LRU 缓存。

### 4.4 Prompt Cache 设计点

- `system` 片段完全来自 `PromptTemplateService.loadSystemSkeleton()`，内容由 DB 管理，版本切换粒度为 `run`。
- 不在系统骨架中注入：时间戳、用户姓名、会话 ID、当前工作区文件数。
- Memory bootstrap、workspace 索引、skill 索引统一在 §3.2 ③ 处按稳定顺序注入，避免漂移。

## 5. 工具协议

### 5.1 核心类

| 类 | 作用 |
|---|---|
| `BuiltinToolDefinition` | 工具定义（name + schema + category + risk + mode） |
| `ToolExecutor` | 函数式接口；`execute(ToolContext, Map args)` → `ToolResult` |
| `ToolRegistry` | 注册 / 查询 / 转 ToolSpec |
| `ToolExecutorDispatcher` | 统一入口：perm + rate + 执行 + audit |
| `ToolResult` | 统一返回（status ∈ ok/error/pending，canonical JSON 回写 tool message） |
| `SchemaBuilder` | 最小 JSON Schema helper |

### 5.2 同步 / 异步

- **SYNC**：当前 Run 内拿到最终结果。
- **ASYNC_PENDING**：一定返回 `pending` + `pendingRequestId`，Run 退出为 `pending`。

交互工具（`confirm_action` / `ask_user`）与 `save_to_oa`（OVERWRITE_ORIGINAL / SUBMIT_FOR_APPROVAL 分支）都走 pending。

### 5.3 6 层实现

Phase 2 交付全部 7 组工具类，统一通过 `BuiltinToolRegistrar.registerAll()` 装配。

## 6. LLM 管理层

### 6.1 ModelProfile 关键字段

`modelId / provider / protocol / endpoint / apiKey / nativeToolCalling / streaming / reasoning / multimodal / contextWindow / maxOutputTokens / firstTokenTimeoutMs / totalTimeoutMs / serviceTier / enabled / orgId`。

### 6.2 路由决策链（`ModelRouter.selectPrimary`）

1. Agent.fastModelId / premiumModelId / defaultModelId
2. 组织默认 AgentProfile.defaultModelId
3. `listByTier(standard)`
4. 全表 enabled first

### 6.3 降级链

`route.primary` → `listByProvider(primary.provider)` 去除自身 → `listByTier(fallback)` → 异常抛 `llm.exhausted`。

流式调用中若 `onDelta` 已开始，**不再降级**——避免下游前端收到两次起始分片。

### 6.4 协议适配器

- `OpenAiCompatibleAdapter`：标准 chat-completions；多模态请求自动转 `content[]` 数组；tool_calls 解析为 `ToolCall`。
- `OllamaAdapter`：`/api/chat` NDJSON；检测到 tools 时自动降级非流式并回放分片；tool_calls 末帧为准。
- 未来加 `VllmAdapter` / `AnthropicAdapter` 只需实现 `LobsterLlmAdapter`。

### 6.5 审计

`ModelCallLog` 每次调用都写：输入/输出 token、duration、downgraded、downgradeReason、status/error。

## 7. 存储设计

### 7.1 ContentStore

- 路径规范：`{category}/{yyyy}/{MM}/{dd}/{userId}/{uuid}.{ext}`。
- 类别：`artifact` / `workshop` / `tool-result` / `message` / `media`。
- `FileSystemContentStore` 实现：路径穿越防护（`root.resolve(ref).normalize().startsWith(root)`）；UTF-8；按 offset/limit 分段读；delete 物理删除。
- 大文件清理策略：通过 `Artifact.status`（`active / archived / deleted`）联动定时任务（未实现，留给 ops）。

### 7.2 轻量 transcript

`ThreadMessage.content` 直存短文本（< 4KB），超过则外置到 `message/` 类别，`contentRef` 指向文件；读取时 `ThreadService.resolveFullContent` 按需展开。

### 7.3 Artifact REST API

`ThreadApi` 暴露三个用户级 artifact 端点，均做 owner 校验（`a.getUserId().equals(user.getUserId())`）：

| 端点 | 用途 | 响应 |
|---|---|---|
| `GET /ai/api/artifacts/{id}` | 读文本内容（JSON），支持 `offset` / `limit`。`limit ≤ 0` 或省略 → 全文，`limit > 0` → 上限 16 000 字符窗口 | `{ artifactId, title, content, format, version, truncated, ... }` |
| `GET /ai/api/artifacts/{id}/download` | 原件下载，`Content-Disposition: attachment` | 二进制字节流（DownloadFile） |
| `GET /ai/api/artifacts/{id}/preview` | **内联预览**，`Content-Disposition: inline`——供前端 `<iframe>` / `<img>` 直接 src。格式白名单：`html` / `pdf` / 图片 / 纯文本；其它格式抛 `artifact.preview_unsupported`（防 MIME sniff） | 二进制字节流（DownloadFile，`setAttachment(false)`） |

**前端集成（`ArtifactPreviewDrawer.vue`）按 `artifact.format` 分支：**

- `html` → `<iframe sandbox="allow-scripts allow-forms allow-popups allow-modals" :src="previewUrl">`（**不含 `allow-same-origin`** → iframe 为 null origin，无法读父窗 cookie / storage）
- `pdf` → `<iframe :src="previewUrl">`（浏览器原生 PDF viewer）
- `png/jpg/jpeg/gif/webp/svg/bmp/ico` → `<img :src="previewUrl">`（SVG 走 img 而非 iframe，`<img>` 不执行 SVG 内嵌脚本）
- `docx/xlsx/pptx/...` → "下载原件"按钮（浏览器无法原生渲染 Office 二进制）
- 其它（txt/md/json/csv/xml/log） → 原 `<pre>` + 文本读取接口

**安全分层**（自外而内）：

1. 后端 `isInlinePreviewable(ext)` 白名单拦截 —— 防未知二进制被 MIME sniff 成 HTML 执行
2. 后端 owner 校验 —— 仅 artifact 所有者能 preview
3. 前端 iframe `sandbox` 属性（无 `allow-same-origin`）—— iframe null origin 隔离 cookie / storage / top-navigation
4. 前端 `referrerpolicy="no-referrer"` —— 不泄露父页面 URL 给 iframe 里的脚本

**已知约束**：`net.cyan.commons.util.io.DownloadFile` 不暴露自定义 response header API，暂无法叠加 `X-Content-Type-Options: nosniff` / `Content-Security-Policy: sandbox` 做二道加固。未来扩展 cyan 后补上。

**web-artifacts-builder skill 的典型链路**：agent 跑 code_exec → skill 里 parcel + html-inline 打出**单文件自包含 HTML** → `cp /outputs/<name>.html` → harvestOutputs 落为 `Artifact(format='html')` → 前端点 artifact → Drawer 调 `/preview` 端点 → iframe 渲染完整 React/Tailwind/shadcn 应用。单文件设计对 iframe 预览极友好：所有资源内联，null origin 下也不需要 fetch 外链。

## 8. 护栏层

### 8.1 AgentLoopDetector

- `MAX_IDENTICAL_CALLS = 3`、`MAX_TURNS_WITHOUT_PROGRESS = 8`
- `observe(toolCalls, madeProgress)` 分两次调用：先记录工具列表，再在执行后标记是否有 ok。

### 8.2 ClaimConsistencyChecker

- 关键词匹配「已读取/已写入/已生成/已保存」等；若本轮没有 ok 工具结果则 WARN。
- Runtime 将 WARN 作为 `system_hint` 写入 transcript，让下一轮 LLM 自行纠偏，不阻断当前输出。

### 8.3 InternalInfoSanitizer

正则脱敏：
- 内置工具名（含 `oa_*`）
- 绝对路径（Windows `C:\` / Unix `/xxx`）
- 内部 ID 前缀（`th_` / `msg_` / `run_` / ...）

### 8.4 ContentFilter（`KeywordContentFilter` 兜底）

生产需接入组织敏感词库（`QuotaPolicy` / 独立表），首期提供最小实现。

## 9. 配额、并发、熔断

- `ConcurrencyGuard`：按 `userId` / `threadId` 维护 `AtomicInteger` 计数器；`Acquisition` 实现 `AutoCloseable`，try-with-resources 自动回收。
- `ToolRateLimiter`：按 `userId|toolName` 滑动窗口；风险等级默认：READ 120/min、WRITE 60/min、BATCH_WRITE 20/min、DESTRUCTIVE 10/min。
- `CircuitBreakerRegistry`：CLOSED → OPEN（failureThreshold）→ HALF_OPEN（openMillis 冷却后）→ CLOSED。
- 所有限流/熔断被触发时，**Run 结束、thread 仍可工作**（设计文档硬要求）。

## 10. SSE 流式

### 10.1 事件字典（`StreamEventType`）

`run_started / assistant_text / plan_update / tool_call / tool_result / pending_request / run_ended / system_hint / error`。

### 10.2 SseStreamEmitter

- `AsyncContext.setTimeout(0)` 长连接；`X-Accel-Buffering: no` 防 nginx 缓冲。
- `emit()` 内部同步化，避免多线程并发 write 破坏帧结构。
- 业务侧不直接持有 `HttpServletResponse`，全部通过 `StreamEmitter` 接口，方便单测 / 离线 Run。

### 10.3 与前端契约

- `messages`（持久化历史）与 `streamingAssistant`（当前 live text）必须分开保存，不做 per-token 合并（避免 reconciliation 抖动）。
- 工具进度 `tool_result` 事件的 `summary` 简洁描述（如 `"读取文件 xxx.docx"`），完整结果走 artifact / transcript 查询 API。

## 11. 多模态（骨架）

- `LobsterMessage.userWithImages(text, imageUrls)` 承载图像 URL。
- `OpenAiCompatibleAdapter` 已生成 vision content：`[{type:"text", text:"..."}, {type:"image_url", image_url:{url:"..."}}]`。
- 完整 `media` 表 + `/api/media/upload` / `/api/media/{id}` 路由，按 `design-big-lobster-multimodal` 下一阶段接入。

## 12. 约定 / 落地规范

- 所有实体字段：数字 / 字符串类型必带 `@ColumnDescription(type=...)`；关联对象加 `@NotSerialized`。
- **枚举字段不要写 `@ColumnDescription`**：thunwind 自动建表会正确生成类型和注释；如果加了带 `defaultValue` 的 `@ColumnDescription`，生成的 DDL 会是 `default STANDARD`（未加引号）被 MySQL 拒绝。默认值请在 CRUD/Service 的 `initEntity` 或 `beforeInsert` 里代码设置。`@Index` 等其他注解仍可单独保留。
- `@Service` 注解写法：**普通 API 类上只写 `@Service`（不带 url）**；每个对外方法单独写 `@Service(url="完整路径", method=HttpMethod.xxx)`，URL 一次写全不拼接。历史代码里类上带 `url=` 的写法仅保留，不作为新代码范本。**唯一例外**：继承 `BaseNormalCrud` 等 CRUD 基类的后台类，类上必须带 `@Service(url="/xxx/path")`——CRUD 框架按此前缀自动挂 list / add / update / delete 动作。
- **URL 全局唯一**：Arachne 路由只按 url 精确匹配，**不考虑 HttpMethod**。不允许出现"同 url + 不同 method"的两条端点，启动时会冲突。实践中采用如下命名：
  - 集合：`GET /ai/api/xxx/list`、`POST /ai/api/xxx/create`
  - 单项：`GET /ai/api/xxx/{$0}`（唯一 GET）、`POST /ai/api/xxx/{$0}/update`、`POST /ai/api/xxx/{$0}/delete`
  - 动作：`POST /ai/api/xxx/{$0}/<action>`（如 `/enabled` / `/cancel` / `/resolve`）
  - `HttpMethod` 只在 `get` / `post` / `all` 中选；变更类统一 `post`，不用 `put` / `delete`（无语义收益，且容易让人误以为能与 GET 复用同一 url）。
- **nest `@Inject` 约束**：
  - 字段类型优先用**具体类**（`@Inject FileSystemContentStore`）。按接口注入 + `nest.xml <bean class=接口 imp=实现>` 在 zmeg_new 框架自身类上 work，但 lobster 的接口实测不可靠，2026-04-21 已全部改走具体类。
  - 被 `@Inject` 的具体类**必须有 public 零参构造器**；nest 走 `newInstance()` 实例化，带参构造器会被它放弃并注入 null。如需初始化外部参数（路径、URL 等），在零参构造器里读 `catalina.base` 或 `LobsterConfig`。
  - 调用处对 `@Inject` 字段加 null 判断（参考 `AgentRuntime.streamOneTurn` 里对 `contentFilter` 的兜底），把"DI 配漏"降级成"特性没生效"而不是"整轮对话 NPE"。
- 日志走 `Tools.log(...)`；不允许 `System.out`。
- 异常：业务级 `LobsterException(code, message)`；未登录用 `"tool.auth"` / `"thread.auth"` 等明确 code。
- 所有 transcript / audit / memory 字段都必带 `userId` 并走 `Index`。
- 时间字段统一 `java.util.Date`；日期格式化走 `JsonUtil` 默认（ISO-8601）。

## 12.1 全局配置（LobsterConfig + lobster.xml）

- 所有"非业务行、但需要部署期可调"的参数都落在 `com.gzzm.lobster.config.LobsterConfig`：静态字段写死默认值，保证 XML 未加载时也能启动；每个字段配 public JavaBean setter，由 cyan 的 `<config>` 机制在启动期从 `web/WEB-INF/config/lobster.xml` 读值覆盖。
- 业务代码统一通过 `LobsterConfig.getXxx()` 读取，不直接引用字段，方便以后换带热更新的实现。
- 已收敛的配置项：agent 最大轮数、上下文 token 预算兜底（实际预算按 ModelProfile.contextWindow 动态算）、LLM 单轮超时、并发 run 上限（user / thread）、memory 正文上限与写入限速、工具限速、列表默认/最大页大小、admin 角色名、apiKey 脱敏尾部长度、`summarizerEnabled` / `summarizerMaxInputChars` / `summarizerModelId`（LLM 历史摘要开关 + 输入预算 + 专用模型）。
- 扩展新配置项：在 `LobsterConfig` 加 `private static` 字段（带默认值） + getter + setter，在 `lobster.xml` 的 `<com.gzzm.lobster.config.LobsterConfig>` 块里加同名子标签。

## 12.15 LLM I/O 全量追踪

`LlmRuntime.saveAudit` 在写 `ModelCallLog` 元数据之外，如果 `LobsterConfig.llmTraceEnabled=true`（默认开），把本次调用的**完整输入输出**通过 `ContentStore` 落盘：

- 路径：走 `ContentStore.write("llm-trace", userId, json, "json")`，默认 `FileSystemContentStore` 落到 `llm-trace/{yyyy}/{MM}/{dd}/{userId}/{uuid}.json`
- 返回的 ref 存到 `ModelCallLog.traceRef`
- 内容字段（JSON）：
  - 顶部：callId / runId / threadId / modelId / provider / protocol / endpoint / streaming / durationMs / status / tokens / createTime
  - `request.messages`：完整 send-view（system skeleton + 动态规则 + skill 索引 + memory + workspace + plan + transcript + 当前输入）
  - `request.tools`：本轮可调用的 ToolSpec 列表
  - `response.assistantText` / `toolCalls` / `finishReason` / `rawText` / `streamedText`（流式中途断流保留已推送的部分）
  - `response.error`：class + message + 完整 stackTrace

查询入口：`AdminCallLogApi`，三端点：

| URL | 用途 |
|---|---|
| `GET /ai/api/admin/calls/list?runId=&threadId=&offset=&limit=` | 列表；支持按 runId 或 threadId 筛 |
| `GET /ai/api/admin/calls/{callId}` | 单条元数据 |
| `GET /ai/api/admin/calls/{callId}/trace` | 读 traceRef 对应的完整 JSON |

**隐私 / 合规要点**：

- `apiKey` 不进 trace（只取 `provider / protocol / endpoint`）
- `messages` 包含用户输入和 transcript，属于敏感内容，ContentStore 路径应放在受限访问目录
- 体积：每轮 10KB–200KB，一天几千轮可能几 GB；生产建议定期归档 / 删除旧目录

**关掉 trace**：`lobster.xml` 把 `<llmTraceEnabled>false</llmTraceEnabled>`，不影响 `ModelCallLog` 元数据继续写。

## 12.2 后台管理 CRUD

工具/Skill/模型/MCP Server 四类可维护集合，同时提供两套管理入口：

### REST 风格：`/ai/api/admin/*`

| 域 | 入口 | 底层实体 |
|---|---|---|
| 工具治理 | `AdminToolApi` → `/ai/api/admin/tools/*` | `ToolDefinitionConfig` |
| Skill | `AdminSkillApi` → `/ai/api/admin/skills/*` | `SkillDefinition` |
| 模型 | `AdminModelApi` → `/ai/api/admin/models/*` | `ModelProfile` |
| MCP Server | `AdminMcpServerApi` → `/ai/api/admin/mcp-servers/*` | `McpServerConfig` |

每组 6 个端点：`list(offset,limit)` / `get(id)` / `create(...)` / `update(id,...)` / `delete(id)` / `toggleEnabled(id, enabled?)`。URL 见 `PROGRESS.md` 第 2.7 节。

实现约束：

- 所有 handler 第一行调用 `AdminGuard.requireAdmin()`；角色名由 `LobsterConfig.getAdminRoleName()` 决定。`UserContextHolder.get()` 默认从 zmeg_new 框架 `UserOnlineInfo.getUserOnlineInfo()` 自动桥接当前登录态，框架管理员（`isAdmin()=true`）天然携带 `admin` + `ai_admin` 角色，无需在 API 入口手动 `set`。
- 分页 `offset` / `limit` 走 `LobsterConfig.getListDefaultPageSize()` 与 `getListMaxPageSize()` 兜底。
- `AdminModelApi` 回显用 `apiKeyMasked`（保留尾部 `apiKeyMaskTailLen` 位），更新时若传入字符串含 `*` 或为空字符串则视为"未修改"，避免误清空。
- 新增/更新中对枚举值采用 `EnumType.valueOf(String)`（`ModelProvider` / `ModelProtocol` 用 `fromString(String)`，兼容连字符与下划线两种形态），前端按 `ToolCategory` / `ToolRiskLevel` / `ToolExecutionMode` / `SkillScope` / `SkillRuntimeKind` / `ModelServiceTier` / `McpTransportType` / `ModelProvider` / `ModelProtocol` 取值列表即可。
- 删除走 DAO 的 `@OQLUpdate("delete from ... where id=?1")`，注意与表上的 `keys` 字段保持一致。

### zmeg_new 框架 CRUD 类：`/ai/admin/*`

| 域 | 入口 | 底层实体 |
|---|---|---|
| 工具治理 | `ToolDefinitionCrud` → `/ai/admin/tool` | `ToolDefinitionConfig` |
| Skill | `SkillDefinitionCrud` → `/ai/admin/skill` | `SkillDefinition` |
| 模型 | `ModelProfileCrud` → `/ai/admin/model` | `ModelProfile` |

继承 `BaseNormalCrud<E, K>`，类上挂 `@Service(url="/ai/admin/xxx")`——这是"类上不带 url"规则的唯一例外，CRUD 框架依赖这个 url 前缀自动注册 list / add / update / delete / sort 等动作与页面。

关键约定：

- `@Like` 单字段模糊筛选（tool→toolName，skill→name，model→modelId）；`addOrderBy("updateTime", OrderType.desc)` 默认按更新时间倒序。
- `createListView()` 用 `PageTableView` 定义筛选条件和列；布尔列用 OGNL 三元表达式 `"(xxx==null||xxx)?'是':'否'"` 避免 null 呈现为空。
- `createShowView()` 用 `SimpleDialogView` 定义编辑表单；Boolean 字段用 `CCheckbox`，长文本用 `CTextArea`。Enum 字段框架可自动渲染，无需显式 Select。
- `initEntity(E)` 设置新建默认值；`beforeInsert` / `beforeUpdate` 维护 `createTime` / `updateTime`，Skill 在 `beforeInsert` 自动生成 `sk_` 前缀 ID，在 `beforeUpdate` 把 `version` 自增。
- `ModelProfileCrud` 对 `apiKey` 采用占位符 `######$$$$$$` 回显（只对现有记录），`beforeUpdate` 检测占位符即 `setApiKey(null)`，框架会跳过 null 字段——不会误清空原值。
- CRUD 类与 REST API 共用底层 DAO 和实体；URL 路径互不冲突（`/ai/admin/*` vs `/ai/api/admin/*`）。内部管理台建议优先走 CRUD；第三方调用走 REST。

## 13. 关键扩展点

| 扩展点 | 如何接入 |
|---|---|
| 新工具 | 新增 `XxxTools` 实现；在 `BuiltinToolRegistrar.registerAll` 里添加一行；通过 `POST /ai/api/admin/tools` 或直接写 DB `AI_TOOL_DEFINITION` 声明风险等级 |
| 沙箱 / code_exec / Skill 资产 | 独立子文档 [`SANDBOX-SKILL-IMPL.md`](SANDBOX-SKILL-IMPL.md)，覆盖设计、运行时时序、镜像、审计、限流、Admin API、故障排查 |
| 新协议 | 实现 `LobsterLlmAdapter`；在 `LobsterAdapterFactory.create` 里识别 `provider` |
| 新 MCP Server | 通过 `POST /ai/api/admin/mcp-servers`（或写 DB `AI_MCP_SERVER_CONFIG`）；重启或调用 `McpToolBridge.refresh()` |
| 新 Skill | 通过 `POST /ai/api/admin/skills`（或写 DB `AI_SKILL_DEFINITION`）+ guidance；LLM 调用 `list_skills / use_skill` 即可生效 |
| 新 Agent | 写 DB `AI_AGENT_PROFILE`；用 `ModelSelectionContext.agentId` 切换 |
| 新模型 | 通过 `POST /ai/api/admin/models`（或写 DB `AI_MODEL_PROFILE`）；`ModelRouter` 下个路由周期即可命中 |
| 新内容过滤器 | 实现 `ContentFilter`；替换 `KeywordContentFilter` 注入 |
| 新全局参数 | 在 `LobsterConfig` 加字段 + setter，`lobster.xml` 写初始值 |

## 14. 已知约束

- LangChain4j 使用 system-path 直连 HTTP；未来替换为 `OpenAiChatModel` / `OllamaChatModel` 时保持 adapter 接口不变。
- `zmeg_new` 的 DI/IoC 容器（`@Inject`）需要依赖实际 JAR；测试代码通过反射 field 注入绕开 DI。
- `PendingRequestApi.myPending` 直接调 `PendingRequestDao.listOpenByUser`；若迁移至按状态机查询需新增 OQL。
- OA 接口（`OaFileClient` / `OaKnowledgeClient`）提供 stub，生产替换为真实客户端。
- MCP 桥接目前仅注册 `{namespace}.ping` 占位，真实 MCP Server 接入时用 `langchain4j-mcp` 的 `McpClient.listTools()`。

## 15. 开发者指引

- 编译：`mvn -DskipTests package` 生成 WAR 部署到现有容器。
- 测试：`mvn test` 运行 JUnit 用例。
- 新增 Entity：`@Entity` + `keys` + 必要的 `@ColumnDescription`；数据层会走自动建表。
- 新增 API：在 `api` 包下建类，类上 `@Service`，每个方法上 `@Service(url="/ai/api/...", method=HttpMethod.xxx)`；方法参数按 URL 模板（`{$0}` 等）与 query/body 自动匹配；后台管理类 API 放 `api/admin/` 子包并首行调用 `AdminGuard.requireAdmin()`。
- 新增测试：将类放在 `test/com/gzzm/lobster/...`；DI 依赖用 Mockito + 反射注入 `AgentRuntime` 私有字段那样即可。

## 16. 后续迭代推荐顺序

1. 接入真实 OA 文件 / 知识库客户端（替换 `OaFileClientStub` / `OaKnowledgeClientStub`）。
2. 接入 LangChain4j McpClient 完成 MCP 工具真实发现。
3. 引入 `media` 表 + 多模态上传。
4. 丰富 QuotaPolicy：按组织配置动态生效（目前是兜底默认值）。
5. 建立 Prometheus / SLS 观测：从 `ModelCallLog` + `AuditLog` 聚合 QPS / 成功率 / P95。
6. Prompt A/B：在 `PromptTemplate` 基础上加灰度字段。
