# M4 AI 工具链从 Provider 到角色边界：为什么先做最小闭环

到了 `M4`，项目终于开始触碰一个很容易让人“越做越重”的主题：AI。

很多教程一进入 AI 阶段，就会直接开始接 SDK、写 prompt、调大模型、上工作流，最后读者看到的是一堆能跑的代码，却很难说清楚系统边界到底是什么。对于这个项目来说，这种推进方式并不适合。

因为我们做的不是一次性演示，而是一个要能继续演进、能继续接教程、还能控制成本和权限边界的开源项目。

所以 `M4` 解决的不是“AI 功能越多越好”，而是先回答四个基础问题：

1. 系统怎么统一接不同的 AI Provider？
2. 在进入分析前，文本输入从哪里来？
3. 结果结构怎么先稳定下来？
4. 谁能触发真实分析，谁只能读演示结果？

这篇文章就围绕这四个问题，讲清楚为什么 `M4` 先做最小闭环，而不是直接把真实 AI 工作流一口气铺满。

## 一、为什么 `M4` 不能一上来就“接大模型然后开干”

AI 功能最容易诱发的一种错误节奏，就是一开始就把注意力放在“模型调用成功了没有”。

但对教程型项目来说，真正危险的地方不是模型没调通，而是边界没收住。比如：

- 业务代码里到处直接调用某个厂商 SDK
- 上传、文件提取和分析调用分散在前后端不同地方
- mock 结果、缓存结果和真实结果结构不一致
- `viewer` 和 `admin` 都能直接触发分析，导致成本和权限一起失控

一旦用这种方式推进，后面每加一个功能都会更痛苦：

- 换厂商要改很多地方
- 加缓存要回头改接口结构
- 做只读体验时发现根本没有稳定的演示数据
- 接真实 provider 时才发现前面没有统一入口

所以 `M4` 的目标不是先把“智能程度”做高，而是先把 AI 链路的入口、输入、结果结构和角色边界收清楚。

## 二、`M4` 真正要交付什么

这一轮的交付目标可以概括成一句话：

让读者能看到一条最小 AI 工具链如何从 Provider 抽象出发，经过文件提取和缓存报告，最后形成 `admin` 可触发、`viewer` 可只读的稳定闭环。

这里面有四个非常关键的“先做一点点”。

### 1. 先抽 Provider，而不是先绑定某个厂商

用户当前用的是七牛云兼容接口，后面也可能继续切换到 DeepSeek 或其他 OpenAI-compatible 服务。如果一开始就把厂商写死在业务里，后面每次切换都会牵动接口、配置和服务层。

所以 `M4` 第一件事就是抽象出统一 Provider 接口，并把真正的模型调用都收敛到 `AiService`。

这样做的好处是：

- mock provider 和真实 provider 能共存
- 七牛云、DeepSeek、其他兼容厂商能共用一层适配
- 后面的业务服务不用关心底层具体是哪家

### 2. 先做统一文本提取入口

AI 分析的前提，不是 prompt，而是先拿到稳定文本。

如果没有统一文件提取入口，后面每个分析接口都要自己处理：

- 文件上传
- 类型识别
- 文本解析
- 错误反馈

这会让结构很快分散。

所以 `M4` 第二步先把 `txt / md / pdf / docx` 的最小文本提取统一放到 `NestJS` 里，保证后面所有分析链路都有同一个输入入口。

### 3. 先做缓存报告结构，再接真实分析

很多人做 AI 会想“既然都到这里了，就直接调模型生成结果”。但在教程型项目里，缓存结构往往比实时调用更值得先做。

因为一份稳定的 mock 报告结构可以先解决三件事：

- 演示链路先成立
- `viewer` 有可读取的数据
- 后续真实 AI 接入时不需要重新改接口契约

所以 `M4` 并不是回避真实 AI，而是先把“结果长什么样”固定下来。

### 4. `admin` 触发与 `viewer` 只读必须拆开

AI 能力最容易失控的地方，不在“能不能看结果”，而在“谁能触发调用”。

如果 `viewer` 也能直接触发分析，前面在 `M2` 固定下来的角色边界就会立刻失效，后续成本控制也会变得很模糊。

所以 `M4` 刻意把两层能力拆开：

- `admin`：可以触发分析、可以写入缓存
- `viewer`：只能读缓存和预设结果，不能发起真实调用

这一步是整个 AI 工具链里非常关键的边界控制。

## 三、`M4` 为什么要按这 4 个 issue 来拆

这一轮的拆法，其实是在回答一个很典型的问题：

当一个模块天然容易“长得很快”时，应该先收哪些边界？

`M4` 的答案是：

先收调用入口，再收输入入口，再收结果结构，最后收角色边界。

### issue-13：先立 Provider 适配层

第一步不是接口路由，不是 prompt，而是先定义统一 Provider 接口和 `AiService` 入口。

这一步真正固定下来的是：

- AI 调用的统一抽象
- `mock` 与真实 provider 的共存方式
- `.env.development.local` 到运行时配置的映射

这一层一旦成立，后面文件提取、缓存报告、分析接口都可以站在稳定入口上继续长。

### issue-14：再补文件提取入口

Provider 抽象有了之后，下一步不是马上去做“JD 匹配”或“简历建议”，而是先解决输入。

因为没有稳定输入，再好的分析接口也只是一个空壳。

这一步主要收住的是：

- 常见简历文件的最小解析能力
- 统一的上传与错误反馈
- 服务端统一处理，而不是前后端各做一套

这样后面的分析接口就不再需要每个都自己处理文件问题。

### issue-15：把缓存报告结构先固定下来

当输入稳定之后，下一步也不是立刻追求“真实 AI 输出”，而是先让结果结构稳定。

这一轮的重点是：

- 相同输入复用同一份缓存结果
- 不同场景有稳定的 key 和 report 结构
- 可以通过 `reportId` 再次读取

这让系统第一次有了“可演示、可复用、可继续增强”的 AI 结果层。

### issue-16：最后落角色边界

当前面的 Provider、输入和结果结构都成立后，才进入“谁能触发”这个最关键的边界。

这一步不是简单地加个鉴权，而是把 `M2` 的角色模型继续延伸到 AI 模块：

- `viewer` 只能看缓存和 demo 报告
- `admin` 才能触发分析和写入缓存

这样，AI 能力就不是孤立增长的，而是继续服从整个项目已经建立起来的权限主线。

## 四、这一轮最重要的设计取舍

### 1. 为什么先做 OpenAI-compatible 适配，而不是专门为单一厂商写死

当前用户配置里已经有七牛云、DeepSeek 这样的兼容接口线索，这说明系统很可能长期面对“同协议、多厂商”的情况。

这时最合适的做法，不是为每家单独写一套业务逻辑，而是先把共同协议层抽出来，再保留 provider 工厂做分发。

这样做的价值不只是方便切换厂商，更重要的是让教程更容易讲清楚：业务层依赖的是统一接口，而不是某个具体 SDK。

### 2. 为什么 `.env.development.local` 也属于这一轮的关键问题

很多人会以为“把环境变量写进文件里就算配置完成了”，但如果服务端根本没有按预期读取这些文件，前面的适配器抽象就落不到真实运行时。

所以 `M4` 把 env 搜索顺序也一起收住，其实是在解决一个很实际的问题：

教程里不仅要讲抽象，还要保证读者本地真的能把当前配置跑起来。

### 3. 为什么文件提取必须在 `NestJS` 做统一入口

如果把上传和提取逻辑放到前端，或者在 `Next.js` 里再复制一层处理，很快就会回到我们一直在避免的“双后端”问题。

对于这个项目来说：

- `NestJS` 负责统一业务 API
- 文件提取属于 AI 业务的前置基础设施

所以它就应该放在 `apps/server`，而不是散落在多个应用壳里。

### 4. 为什么先缓存、后实时

很多人会把缓存看成“优化”，但在教程型 AI 项目里，缓存首先是一个结构问题，不只是性能问题。

先有缓存结构，意味着：

- 返回结果的 shape 先稳定
- 演示数据和真实数据能共用读取接口
- `viewer` 只读体验有真实落点

这比一开始就追求实时调用，更有助于把系统边界讲明白。

### 5. 为什么 AI 角色边界要单独建 issue

如果在缓存报告阶段就把角色边界一起做完，读者会很难分清：

- 哪部分是在解决“结果结构”
- 哪部分是在解决“谁能触发”

所以 `M4` 把它们拆成两个 issue，本质上是在保护教程节奏，让每一步都只回答一个关键问题。

## 五、关键文件应该怎么读

如果你想顺着代码复盘 `M4`，建议优先看下面这几组文件。

### Provider 与统一入口

- `apps/server/src/modules/ai/ai.service.ts`
- `apps/server/src/modules/ai/config/ai-config.ts`
- `apps/server/src/modules/ai/providers/mock-ai.provider.ts`
- `apps/server/src/modules/ai/providers/openai-compatible-ai.provider.ts`

这里解决的是“AI 调用如何统一收口”。

### 文件提取

- `apps/server/src/modules/ai/file-extraction.service.ts`
- `apps/server/src/modules/ai/ai-file.controller.ts`

这里解决的是“分析前的文本输入如何统一进入系统”。

### 缓存报告

- `apps/server/src/modules/ai/analysis-report-cache.service.ts`
- `apps/server/src/modules/ai/ai-report.controller.ts`

这里解决的是“报告结构如何先稳定下来”。

### 角色边界

- `apps/server/test/ai-report-role-access.e2e-spec.ts`
- `apps/server/src/modules/auth/guards/role-capabilities.guard.ts`

这里解决的是“AI 能力如何继续服从 `admin / viewer` 的角色约束”。

## 六、`M4` 的测试重点是什么

`M4` 的测试重点不是“模型回答得好不好”，而是 AI 工具链的结构边界有没有被锁住。

### 1. 先锁 Provider 与配置解析

这里至少要证明：

- 未配置时可以回落到 `mock`
- 七牛云 / DeepSeek 能正确映射为兼容配置
- 缺少必要凭证时会明确报错

这一步锁住的是“系统如何选择并创建 provider”。

### 2. 再锁文件提取入口

文件提取测试要证明：

- `txt / md / pdf / docx` 能拿到文本
- 不支持的类型会被明确拒绝
- 接口层和服务层的职责是清楚的

这一步锁住的是“输入边界”。

### 3. 再锁缓存报告行为

缓存报告测试要证明：

- 相同输入会命中同一份结果
- 可以通过 `reportId` 读取缓存详情
- 不同场景不会错误复用

这一步锁住的是“结果结构边界”。

### 4. 最后锁角色访问差异

角色边界测试要证明：

- `viewer` 可以看缓存列表和详情
- `viewer` 不能写缓存，也不能触发分析
- `admin` 可以触发分析并把结果写入缓存

这一步锁住的是“谁能触发，谁只能看”。

## 七、这一轮最容易踩的坑

### 1. 直接把厂商 SDK 写进业务模块

这样短期内看起来推进很快，但后面 mock、缓存、切换 provider 时都会越来越难收。

### 2. 一开始就把真实 AI 任务做成复杂工作流

如果过早引入 Redis、BullMQ、任务编排、Prompt 工作流，教程读者会很快失去对主线的把握。

### 3. 上传和提取逻辑分散在多个前端壳里

这会重新引入“双后端”问题，也会让接口职责模糊。

### 4. 没有给 `viewer` 准备稳定演示数据

如果 `viewer` 登录后什么都看不到，虽然权限边界是对的，但教程体验会很弱，也很难证明“只读缓存体验”这条链路真的成立。

## 八、为什么 `M4` 完成后，`M5` 和 `M6` 会更顺

`M4` 的成果不只是“能调一下 AI”，而是让系统第一次拥有了统一的 AI 输入和结果边界。

一旦这些边界成立，后面很多事都会自然一些：

- `M5` 做导出时，可以围绕稳定的已发布内容和分析结果继续扩展
- `M6` 做测试、CI 和部署时，可以把 AI 相关链路纳入统一质量门禁
- 后续真正要上 Redis / BullMQ / 数据库持久化时，也会更像是“增强现有结构”，而不是推翻重来

换句话说，`M4` 的核心价值不是让 AI 多聪明，而是让 AI 模块第一次变得可持续演进。

## 九、如果你要自己复现 `M4`，建议按这个顺序

1. 先抽统一 Provider 接口和 `AiService`
2. 再补文件提取入口，先把输入拿稳
3. 再做缓存报告结构，让结果先可复用
4. 最后再把 `admin` 触发与 `viewer` 只读的角色边界落到接口层

这个顺序的好处是，系统每往前走一步，都在回答一个清晰的问题，而不是同时处理厂商、文件、结果和权限四种复杂度。

## 十、这篇文章对应的原始材料

如果你想继续往下看每个 issue 的实现细节，可以配合这些开发日志一起阅读：

- `docs/30-开发日志/M4-issue-13-provider-适配器接口.md`
- `docs/30-开发日志/M4-issue-14-文件提取入口.md`
- `docs/30-开发日志/M4-issue-15-缓存分析报告.md`
- `docs/30-开发日志/M4-issue-16-admin-触发与-viewer-只读.md`
- `docs/40-教程与博客/M4-AI-工具链从-Provider-到角色边界-教程大纲.md`

这些记录更适合看“每一步具体做了什么”，而本文更希望回答“为什么 AI 功能要先这样拆”。

## 小结

`M4` 做的不是一套完整 AI 平台，而是先把 AI 模块最容易失控的四个边界收住：

- 统一 Provider 入口
- 统一文本输入入口
- 稳定的缓存报告结构
- 明确的角色触发边界

这些边界一旦成立，后面的真实 provider、队列系统、持久化和后台工作台，都会更像是在已有地基上继续长，而不是重新拼一套系统。
