普通视图

发现新文章,点击刷新页面。
昨天以前首页

Mac 上生成 AppStoreInfo.plist 文件,App Store 上架

2026年5月22日 17:05

在 macOS 上上传 IPA 到 App Store Connect 时,有一类问题比较容易发现,就是 IPA 已经导出成功,签名也正常,Xcode Archive 没有报错。但上传阶段却提示 Missing AppStoreInfo.plist或者 Could not locate AppStoreInfo.plist. 很多开发者看到 .plist 文件后,会下意识去手动创建 plist、从旧工程复制,然后解压 IPA 后补文件

结果问题没有解决,反而引入新的 metadata 错误。

这篇文章直接分析,AppStoreInfo.plist 是在哪一步生成的,以及 mac 上如何避免手动维护它。

IPA 内部其实并没有这个文件

有些开发者会unzip app.ipa来 解压 IPA ,然后在 Payload 中搜索AppStoreInfo.plist,结果发现根本不存在,这是正常的,因为 AppStoreInfo.plist 并不属于应用内容本身。

它属于 上传 metadata,而不是App Bundle

为什么在 mac 上也会缺失

很多人认为有 Mac 就不会缺 plist,实际上并不是。 下面几种情况就容易出现:

场景 结果
使用命令行上传 metadata 缺失
自定义上传脚本 plist 未生成
CI 上传 只生成 IPA
非 Xcode 上传 没有 metadata 阶段

Xcode 为什么很少报这个问题

因为Archive → Export流程中,Xcode 自动生成 metadata,自动组装上传结构,开发者感知不到。

真正的问题其实在上传阶段

比如 IPA 已签名,可以安装,Bundle ID 正确,但上传时报 plist 错误,说明上传工具缺 metadata 生成能力

不要手动拼 plist

网上有些方法会建议:

<?xml version="1.0" encoding="UTF-8"?>

然后自己写字段。问题在于 Apple metadata 字段会变化、上传协议会更新、不同 Transporter 版本结构不同,如果字段不完整就会上传直接失败

更省事的方法就是上传时自动生成

在 macOS 环境里,一个更稳定的方式是上传工具自动生成 AppStoreInfo.plist

这样不需要维护 plist,也不需要研究 metadata 格式,不需要手工拼 XML。

使用 AppUploader CLI 上传

工具位置

mac 版本:AppUploader.app/runtime/,进入 runtime 后即可使用appuploader_cli ,上传命令:

例如:

appuploader_cli --upload-app -f Payload.ipa -u user@example.com -p xxxx-xxxx-xxxx-xxxx --type ios

或者:

appuploader_cli upload -f Payload.ipa -u user@example.com -p xxxx-xxxx-xxxx-xxxx --type ios

CLI 上传时会自动解析 IPA,读取Bundle ID、Version、Build Number

自动创建 metadata,包括AppStoreInfo.plist,然后调用上传接口上传至App Store Connect

命令行更适合长期项目

如果项目已经接入:

  • Fastlane
  • Jenkins
  • GitHub Actions
  • GitLab CI

GUI 上传会变得不方便。

CLI 更适合:

场景 优势
自动化构建 可脚本化
多项目上传 易批处理
远程服务器 无 GUI 依赖

例如:

  • Flutter 打包 IPA
  • mac mini 作为 CI 节点
  • Jenkins 自动上传

脚本:

./appuploader_cli upload \
-f build/ios/app.ipa \
-u ci@example.com \
-p xxxx-xxxx-xxxx-xxxx \
--type ios

上传时 metadata 自动生成、不需要人工干预 plist

AppStoreInfo.plist 属于 metadata 生成阶段,不是应用构建阶段。

如果上传工具已经支持自动生成 metadata,就没有必要再手动维护 plist 文件。

harmony-next.skills 为 AI 而生!

作者 归故里
2026年5月21日 00:58

如果你最近让 AI 帮你写 HarmonyOS NEXT 代码,大概率遇到过同一个问题:模型很自信,但答案不一定落得回真实文档。

它可能会记错 @ohos.* 模块路径,混用旧版本 API,给出不存在的 ArkUI 参数,或者在 NDK、DevEco Studio、模拟器调试这些边界场景里凭经验补全。尤其是 DevEco 模拟器接口和 DevEco Studio IDE 接口,很多能力不在常规 API 手册里,只靠模型记忆很容易把路径、版本、私有协议和风险边界混在一起。

所以我整理了一个给 AI 编程助手使用的技能库:harmony-next.skills

它不是给人从头翻的文档合集,而是给 Gemini CLI、Claude Code、Codex 这类 Agent 使用的 HarmonyOS NEXT 本地检索层。目标很直接:让 Agent 在回答之前先找到真实文档路径,再读取对应内容,最后基于可追溯的资料写代码、解释 API 或执行本地诊断。

项目地址:

github.com/linhay/harm…

为什么需要这个库

HarmonyOS NEXT 开发里,很多问题看起来只是“查一下 API”,实际会牵出一串上下文:

  • 这个能力属于哪个 Kit?

  • 当前文档快照是否覆盖 API 23?

  • @ohos.* 模块、ArkUI 组件、NDK 头文件是否真实存在?

  • 旧文档链接是否已经迁移到新的 Markdown 页面?

  • DevEco Studio、HarmonyOS Emulator、hdcuitest 能不能在本机自动化验证?

  • DevEco 模拟器的 HVD、target、截图、layout、日志链路该怎么被 Agent 安全调用?

  • DevEco Studio IDE 里的 CodeGenie、MCP、Inspector、Profiler、Doctor 等能力该怎么先静态识别,再决定是否进入真实执行?

  • AI 生成的代码到底是基于真实文档,还是基于模型记忆猜出来的?

harmony-next.skills 想解决的不是“把文档再复制一份”,而是把这些问题变成 Agent 能执行的检索流程。

它把 HarmonyOS NEXT API 12-23 的离线参考资料、Kit 导航、任务导航、全库索引、工具链说明和自动化边界组织在一起。Agent 不需要一上来全量读文档,也不需要凭关键词乱搜,而是按固定路径逐步缩小范围:


SKILL.md

  -> KITS.md / TASK_MAP.md

  -> INDEX.md

  -> 目标 Markdown 正文

这套流程的核心只有一句话:先找路径,再读内容。

它和普通文档镜像有什么不同

普通文档镜像解决的是“离线能不能看”。harmony-next.skills 更关心另一个问题:AI Agent 能不能稳定使用。

所以它不只包含文档正文,还补了几层面向 Agent 的结构:

第一层是 SKILL.md。它告诉 Agent 什么时候应该使用这个技能,遇到 ArkTS、ArkUI、NDK、DevEco Studio、模拟器、hdcuitest 等问题时该怎么路由。

第二层是 KITS.mdTASK_MAP.md。一个按 Kit 缩小范围,一个按开发任务反查关键词。比如你问生命周期、媒体、网络、UI、发布、NDK,不需要从几千个文件里盲搜。

第三层是全库索引。当前参考库包含 3,693 份 Markdown,其中 3,666 份在 JsEtsAPIReference/ 下。Agent 会先命中路径,再打开少量目标文件读取细节。

第四层是验证与维护脚本。参考资料发生迁移或批量改写后,可以用脚本重建索引、检查旧路径残留、审计内部链接是否被误改成纯文本。

这让它更像一套 Agent 用的 HarmonyOS NEXT 知识基础设施,而不是静态资料包。

适合哪些场景

如果你主要用 AI 做 HarmonyOS NEXT 开发,这个库会在这些场景里很有用。

写 ArkTS / ArkUI 时,可以让 Agent 先确认组件、装饰器、状态管理、导航、UIAbility、Want 等 API 的真实位置和版本差异,再生成代码。

做 NDK / Node-API / C API 时,可以通过索引把头文件映射到真实的 topics/**/<header>.h.md 页面,避免旧路径、旧头文件和新文档结构混在一起。

排查工具链问题时,可以查签名、调试、发布、性能、模拟器、真机、hdcaabmhiloghidumper 等本地链路,而不是让模型凭其他平台经验套答案。

DevEco 模拟器接口是这个库里很值得看的部分。它把免 IDE 启动 HarmonyOS Emulator、HVD 枚举、多实例、HDC target 选择、启动等待、uitest dumpLayout、截图、日志采集、应用安装启动这些动作整理成 Agent playbook。重点不只是列命令,而是把每个动作放进可审计的执行模式里:什么时候只做只读探测,什么时候可以保存截图和 layout,什么时候进入 UI 自动化,什么时候需要 diagnostic 或 break-glass 标记。

DevEco Studio IDE 接口也是一个单独亮点。库里整理了 CodeGenie、本地 RAG、MCP、LanceDB、devecostudio://、Previewer、ArkUI Inspector、Profiler、Doctor、Diagnostic、FaultLog、UxTestService 等入口线索。这里的处理方式比较克制:先确认版本和安装路径,默认做静态只读分析,不把私有接口包装成稳定公共 API;涉及 GUI、本地服务、设备连接、用户缓存、聊天历史、模型配置或外部 provider 时,必须明确目标、读取范围、产物目录和脱敏边界。

做自动化 smoke 时,可以复制内置的 Empty Ability 最小工程模板,用 ohpm installhvigorw --mode module、HDC 安装启动、uitest dumpLayout、截图和点击事件完成最小验证。

如果你在研究 DevEco Studio 或 HarmonyOS Emulator 的本地能力,这两份 playbook 会比单纯搜索命令更有用。它们把“能不能做”“怎么做”“做完留下什么证据”“哪些内容不能泄露”放在一起,适合 Agent 长时间跑本地自动化时使用。

最近版本重点

当前本地版本是 v1.3.7

这一版新增了可复制的 HarmonyOS NEXT Empty Ability 最小测试工程模板,和前面两份 DevEco playbook 正好能接起来。默认配置是:

  • bundleName:com.example.emptyability

  • module:entry

  • ability:EntryAbility

  • compatible SDK:5.0.0(12)

  • target SDK:5.0.0(12)

它的价值不是“又多了一个 demo”,而是给 Agent 一个可以复制、构建、安装、启动、dump layout、点击验证的最小闭环。需要适配 API 22 等目标环境时,可以在复制出的 fixture 中覆盖 SDK 版本,例如 6.0.2(22),并通过 DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk 指向本机 SDK 根目录。

也就是说,Agent 不只是会回答“应该怎么做”,还能在有设备或模拟器的环境里做一轮可回归的 smoke 验证。

怎么接入

Gemini CLI 可以直接安装:


gemini skills install https://github.com/linhay/harmony-next.skills --path harmony-next --scope user

Claude Code 可以下载仓库里的 harmony-next/ 技能目录,放到对应技能目录里使用。也可以把它作为项目上下文附加:


git clone https://github.com/linhay/harmony-next.skills.git

claude --add-dir /path/to/harmony-next.skills/harmony-next

Codex 目前可以把 harmony-next/ 放到官方 skill 扫描路径,例如:


git clone https://github.com/linhay/harmony-next.skills.git

mkdir -p "$HOME/.agents/skills"

ln -s "$(**pwd**)/harmony-next.skills/harmony-next" "$HOME/.agents/skills/harmony-next"

团队项目也可以把 harmony-next/ 复制或软链到目标仓库的 .agents/skills/harmony-next,让项目内的 Agent 自动发现。

我希望它带来的变化

AI 编程助手真正有价值的地方,不是“更会编”,而是能进入一个可验证的工程流程。

在 HarmonyOS NEXT 这种快速演进的生态里,Agent 如果没有本地知识源,很容易把旧经验、旧 API、其他平台的模式和当前项目混在一起。短期看像是节省时间,长期看会增加排查成本。

harmony-next.skills 想把这件事往前推一步:让 Agent 先检索、再引用、再实现、最后验证。

如果你正在用 AI 写 HarmonyOS NEXT 应用,或者正在搭自己的 Agent 开发工作流,可以把这个库接进去试试。它不替代官方文档,也不替代工程判断,但它能让 AI 的回答更少凭空补全,更容易追溯,也更适合放进长期维护的开发链路里。

项目地址:

github.com/linhay/harm…

基于 Harness + SDD + 多仓管理模式的 AI 全栈开发实践|得物技术

作者 得物技术
2026年5月7日 11:12

一、核心理念:Harness 思维 — 让 AI 模仿,而不是凭空创造

全栈 AI 开发最容易踩的坑

全栈 SDD 开发中,最常见也最致命的错误是:让 AI 从零开始写代码。AI 模型具备"通识能力",给它一个需求描述,它确实能生成可运行的代码。但问题在于,这些代码往往是"外星代码":风格不一致(命名规范、目录结构、分层方式与项目现有代码不同)、复用率低(没有利用项目已有的公共组件、工具函数、请求封装)、采纳率低(Code Review 时后端同学看到"外来风格"的代码,会产生大量修改意见)。结果就是:AI 生成了代码,但 Review 成本和返工成本反而更高了。

Harness 思维的核心:给 AI 一个"模仿对象"

Harness(约束)思维的本质是:给 AI 一个已有的实现作为参照,让它照着复刻一份,而不是凭空创造。就像给一个新入职的工程师说"你照着这个模块的风格,写一个类似的",而不是"你自由发挥"——前者往往能更快产出符合团队规范的代码。

image.png

在提示词中体现 Harness

不推荐(凭空创造):

请实现一个结束语管理的 CRUD 接口

推荐(Harness 约束):

请参照现有"场景欢迎语"功能(后端接口 /api/v1/feature/list,前端入口 FeatureTable/index.tsx:53-58)实现"结束语"功能。数据结构、分层方式、命名风格都保持一致。新增场景 code:categoryCode = "SCENARIO_CLOSING"

两者的差距不在于 AI 是否"聪明",而在于你给了 AI 多少约束和上下文。约束越精准,生成代码的可用性越高。

二、全栈工作区搭建与 Codebase Indexing

为什么要搭多仓工作区?

前后端代码通常分布在两个独立仓库。如果分开打开,AI 生成后端接口时看不到前端的调用方式,生成前端代码时看不到后端的返回结构,接口字段对不上是家常便饭。将前后端代码放在同一个工作区下,有三个核心价值,Codebase Indexing:Cursor 对工作区内所有代码进行向量化嵌入,建立语义索引。AI 能跨仓库理解代码关系,生成质量大幅提升。上下文完整:AI 同时能看到前后端代码,接口字段、命名风格自然对齐。SDD 文档集中管理:前后端 SDD 文档在同一工作区,便于接口契约对齐。

Codebase Indexing 的价值

Cursor 的 Codebase Indexing 会对工作区内的代码进行向量化嵌入,建立语义索引。这意味着:当你问 AI "场景欢迎语是怎么实现的",它不需要你手动指定文件,能通过语义检索自动找到相关的 Controller、Service、前端组件。当你让 AI "照着欢迎语写结束语",它会检索到欢迎语的前后端完整实现链路,而不只是单个文件。

前后端放在同一个工作区,Codebase Indexing 覆盖两侧代码。AI 生成后端接口时能参考前端的调用方式,生成前端代码时能参考后端的返回结构。

Tips:Cursor 打开工作区后,首次索引可能需要几分钟。可以在 Cursor 设置查看索引进度。确保索引完成后再开始让 AI 生成代码,效果会明显更好。

Cursor vs Claude Code:选哪个?

在全栈 AI 开发场景下,两款工具各有侧重,下表是实测对比:

image.png

全栈工作区搭建&SDD初始化--内部全栈研发插件

以上述需求为例,工作区结构如下,.claude 和 .cursor 中已对 SDD 能力进行初始化。

三、SDD 驱动的全栈代码生成流程

全栈 SDD 的特殊之处

与纯前端/纯后端 SDD 不同,全栈 SDD 需要:生成两份 SDD 文档(前端一份、后端一份);接口契约对齐,前端 SDD 中的接口调用与后端 SDD 中的接口定义必须严格对应;字段映射一致,前端 VO 中的字段名与后端返回的 JSON 字段名一一对应。

相关概念术语

提示词编写范式

以下是经过实践验证的全栈 SDD 生成提示词模板:

这是一个前后端全栈开发工作区,需要你设计技术接口方案,同时开发前后端项目;首先你需要 cd 到对应前后端应用目录中,创建 sdd 文件;所以你需要生成两份 sdd 文档,之后我会启动两个 agent 分别实现;在生成之前,如果你需要确认某些细节,你应当先确认后生成 sdd 文档。前端应用:service-frontend/sdd-propose  feature/your-feature-name前端修改入口参考:@FeatureTable/index.tsx:53-58 @columns/index.tsx后端应用:service-backend/sdd-propose  feature/your-feature-name后端修改入口参考接口:/api/v1/feature/list需求内容:(附上需求文档或描述,并提供前后端需求点清单)

关键要素解读:

image.png

前后端需求点清单分工示例

前端需求功能点

主要是新增一个后台管理页面的 tab,涉及到搜索、展示、配置新增、删除等;利用内部 SDD 文档工具(如下图)从 PRD 描述和文档图片中提炼出需求点。

内部 SDD 文档工具

左侧导航新增"结束语" Tab。 右侧新增结束语列表页。 字段有:结束语内容、结束语描述、优先级、更新人、更新时间、操作列。

新增 / 编辑弹窗字段:结束语描述、生效日期、生效时段、生效时段、结束语话术(类型和规则这里不一一罗列)。

拖拽排序功能: 点击"排序"按钮进入排序状态,拖拽调整顺序后点击"保存"生效。

后端功能点(含接口清单)

后端功能由 AI 根据前端需求描述自主设计数据表和接口。以下是 AI 在生成 SDD 前需要明确的关键设计问题(这些问题应在提示词中列出,让 AI 先回答再生成)。

接口清单: 列表接口(支持分页,回显数据直接嵌入列表响应,无需单独回显接口)、新增接口、编辑接口(复用新增逻辑,根据 id 更新)、删除接口(逻辑删除,修改删除状态字段)、排序接口(批量更新,需考虑高效实现方案)。

字段设计: 结束语话术内容(数组类型)、结束语描述(文本)、优先级 / 序号(小整型)、更新人(字符串)、更新时间(时间戳)。

需要 AI 在 SDD 中明确回答的设计问题:

  • 主键设计:如何设计主键字段?前端发起编辑、删除时需要传递该字段。
  • 优先级自增逻辑:优先级应基于当前数据条数自增,无需前端传递,由后端自动处理。
  • 排序如何高效更新:批量排序时如何设计接口,避免 N 次单条更新?
  • 嵌套对象如何建表:参考已有的"场景欢迎语"接口,入参中存在嵌套子对象(如下方参考结构)。此类子对象应拆分到多张表,还是序列化为 JSON 字段存单张表?
  • isNextDay 字段含义:次日逻辑的具体含义是什么?前端时段选择器中"次日"勾选状态如何映射到该字段?
  • 列表回显设计:列表接口需要返回完整的回显数据(供编辑弹窗回填),无需单独提供详情接口。

为什么要把这份清单放进提示词?

这份清单做了两件重要的事:前端侧给 AI 完整的 UI 细节,让 AI 知道组件状态、字段约束、交互逻辑,避免它做"最简实现"。后端侧把模糊的设计问题提前暴露,让 AI 在写 SDD 之前先回答这些问题——这正是 Harness 思维的体现:让 AI 参照已有实现(如"欢迎语")来解决"结束语",而不是凭空设计。

SDD 文档产出

一次完整的全栈 SDD 生成,会产出以下文档:

前端 SDD:

  • proposal.md — 需求提案,描述前端要做什么。
  • spec.md — 技术规格,组件设计、接口调用、状态管理。
  • tasks.md — 任务拆分,每个 task 对应一个可执行的代码变更。

后端 SDD:

  • proposal.md — 需求提案,描述后端要做什么。
  • spec.md — 技术规格,接口设计、数据库设计、分层架构。
  • design.md — 详细设计,类图、字段映射、SQL。
  • tasks.md — 任务拆分。

SDD 指令使用说明

典型工作流示例

入门引导:

  1. openspec-onboard(首次,不熟悉才走,引导完整步骤);
  2. openspec-continue-change(提示你下一步要干嘛);
  3. openspec-ff-change(快进)。

场景 A:初次开发

  1. openspec-explore(调研,脑暴);
  2. openspec-propose "..."(生成设计);
  3. openspec-apply-change(写代码);
  4. openspec-verify-change(自测,校验代码与 SDD 文档是否对应);
  5. openspec-archive-change(收尾,归档)。

场景 B:二次开发,修改迭代已有功能

  1. openspec-explore(定位旧代码/旧 spec);
  2. openspec-propose "修改..."(生成变更 Spec);
  3. openspec-apply-change(应用修改);
  4. openspec-verify-change(验证回归);
  5. openspec-archive-change(归档)。

场景 C:二次修改,需求变更

  1. openspec-explore(调研,脑暴);
  2. openspec-propose "..."(生成设计);
  3. openspec-apply-change(写代码);
  4. 发现有问题就用 openspec-explore 修改提案;
  5. openspec-explore "需求变更:xxx"(二次脑暴);
  6. openspec-propose "根据探索结果修改提案";
  7. openspec-apply-change(执行提案中变更内容);
  8. 【可选】openspec-verify-change(验证是否有未完成任务);
  9. openspec-archive-change(归档)。

场景 D:季度大清理

  1. openspec-bulk-archive-change --before 2023-12-31(批量归档)。

总的来说,上述相对来说还是比较繁琐,保持最简使用:想(openspec-propose)、做(openspec-apply-change)、收(openspec-archive-change) 即可。

四、多 Agent 协作:前后端并行开发

为什么需要多 Agent

SDD 文档生成完毕后,前后端的代码生成工作是相互独立的——前端根据前端 SDD 生成组件和页面,后端根据后端 SDD 生成 Controller/Service/Repository。这天然适合并行执行。

Cursor 中的多 Agent 协作

Cursor 支持多个 AI 编程模式并行工作,这是其核心优势之一。全栈开发场景下Tab 1 负责前端代码生成,Tab 2 负责后端代码生成,两个 Agent 同时运行、互不阻塞。

Claude Code 中的 Subagent 能力

Claude Code 内置了 Subagent(子代理)机制,适合命令行场景下的多任务并行。

Subagent 模式

Claude Code 提供了两种多 Agent 协作模式。(下个迭代再实践一下 Team 模式和普通 Subagent 的差别)

image.png

Subagent 配置与使用

Subagent 的核心配置项:

{  "description": "前端代码生成专家",  "tools": ["Read", "Edit", "Write", "Bash", "Grep"],  "permissionMode": "bypass",  "model": "sonnet",  "skills": ["前端编码规范"]}

全栈开发场景中的应用:

 Agent(你在对话的 Claude Code)  ├── Subagent 1:读取前端 SDD,生成前端代码       ├── model: sonnet       ├── tools: Read, Edit, Write, Bash       └── 任务:按照 tasks.md 生成前端组件    ├── Subagent 2:读取后端 SDD,生成后端代码       ├── model: sonnet       ├── tools: Read, Edit, Write, Bash       └── 任务:按照 tasks.md 生成后端接口    └── Subagent 3:(可选)生成接口 Mock 数据        ├── model: haiku        └── 任务:根据后端 SDD spec.md 生成 Mock

多 Agent 实践建议

image.png

五、前后端联调:Mock 数据与分阶段验证

三阶段验证策略

直接联调往往是效率最低的验证方式,推荐采用三阶段分离验证:

阶段 1:前端 Mock 验证  前端代码 + Mock 数据 → 本地跑通页面交互,验证 UI 逻辑阶段 2:后端独立验证  后端代码 → mvn clean compile → 构建通过 → 部署到测试环境阶段 3:前后端联调  前端连接测试后端接口 → 端到端验证

这样做的好处是:前后两端的问题可以提前发现、分别修复;避免在联调阶段才暴露;节省大量排查时间。

Mock 数据编写要点

Mock 数据质量直接决定前端自测的有效性,有三个关键要求:字段名和字段类型必须与后端 SDD 中定义的完全一致;参考已有接口的真实返回数据作为模板,而不是随意构造;覆盖边界场景(空列表、单条数据、多条数据、各字段极值如空字符串、超长字符串、null 值等)。

后端独立构建验证

后端代码不需要在本地完整启动整个 Java 服务,只需编译通过即可验证大部分代码问题。

# 切换到 Java 8 环境(根据项目实际 JDK 版本调整)sdk use java 8# 进入后端项目目录cd service-backend# 编译验证(无需本地启动整个服务)mvn clean compile

编译通过意味着:语法正确、依赖关系正确、类型兼容,是部署前最快速的验证手段。

前后端联调步骤

后端代码提交并部署到测试环境;前端本地开发服务通过代理配置,将 API 请求指向测试后端地址;前端请求携带功能路由标识,确保请求路由到对应的测试环境(而不是其他人的环境);逐接口验证,重点关注字段映射、状态处理、错误场景。

六、警惕 SDD 陷阱:测试如何介入全栈研发

SDD 不等于需求文档

这是 AI 全栈开发中最容易被忽视的问题。SDD 描述的是 "技术上怎么实现" ,而不是 "业务上所有的行为" 。AI 在模仿参考代码生成新代码时,会自动复刻很多隐性功能——这些功能在参考代码中存在,AI 认为是"理所当然"的,所以没有写进 SDD 文档,但实际上已经悄悄实现了。

隐性功能示例

示例 1:变量/表单清除(前端)

// AI 模仿欢迎语弹窗生成结束语弹窗时,自动复刻了"关闭弹窗时清空表单"的逻辑const handleClose = () => {  form.resetFields();   // ← 隐性功能:关闭时清空表单字段  setContentList([]);   // ← 隐性功能:清空内容列表状态  setVisible(false);};

示例 2:数据格式转换(后端)

// AI 模仿已有接口,自动添加了业务逻辑判断if (extendInfo.getIsPermanent()) {    extendInfo.setEffectiveDate(null);   // ← 隐性:永久有效时自动清除开始日期    extendInfo.setExpirationDate(null);  // ← 隐性:永久有效时自动清除结束日期}

示例 3:默认值补齐(后端)

// AI 自动实现了"优先级自增"逻辑,SDD 文档中未提及if (Objects.isNull(req.getSequence())) {    req.setSequence(getMaxSequence() + 1);  // ← 隐性:新增时优先级自动递增}

这些隐性功能可能正是需要的,也可能完全不符合当前需求。问题在于你不知道它们的存在。

测试介入建议

image.png

给测试同学的实操建议:把 SDD 文档当作起点,而不是终点。重点 Review AI 生成的代码,问自己一个问题:"参考功能有哪些隐性行为?这些行为在新功能中是否合适?"

七、综合效益与总结

实践效益

通过本文介绍的"Harness + SDD + 多 Agent"全栈开发方法论,在实际项目中验证的效益如下:采纳率提升,相比传统前后端分离开发,工作区模式可以很好地把项目需求上下文放到一起,更便于 AI 理解需求,设计编码;尤其通过Cursor的索引能力,进一步提高采纳率以及功能实现的完整性。耗时降低,SDD 模式下,AI 分析需求后产生两套 SDD 文档,使得前后端开发完全可以并行;以本需求为例,原本前后端2+4人日需求,在这种模式下,算上环境准备、踩坑时间、联调自测时间,压缩至3人日,提效50%+。调试环节不依赖阻塞,前端功能在全栈开发的视野下,已知数据结构可mock数据自测;后端功能通过远程调试的方式,支持本地打点调试;最终一并上测试环境验证,能够明确知道问题来自于前端还是后端;AI 全栈学习成本骤降,只需掌握入门级别前后端知识,即可介入简单全栈需求开发;提高业务域需求吞吐率。

方法论总结

本文介绍的全栈 AI 开发方法论,核心可以用一张图概括:

本文基于实际全栈开发项目经验整理,所有代码示例已脱敏处理,使用通用命名替代业务专有名词,如有问题欢迎交流探讨。

往期回顾

1.通用 AI Agent 驱动网关路由安全审计实践|得物技术

2.AI驱动:从运营行为到自动化用例的智能化实践|得物技术 

3.生成式召回在得物的落地技术分享与思考

4.立正请站好:一个组件复用 Skill 的工程化实践|得物技术

5.财务数仓 Claude AI Coding 应用实战|得物技术

文 /盖伦

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

🧠 Prisma 表名大写 vs SQL 导出小写问题深度解析(附踩坑与解决方案)

作者 excel
2026年5月23日 21:33

一、背景:你看到的“诡异现象”

在使用 Prisma + MySQL 的过程中,经常会遇到一个非常困惑的问题:

👉 Prisma 里是 Permission / Role(大写开头)
👉 但数据库导出后变成 permission / role(小写)

甚至更极端:

  • Prisma 查询正常
  • SQL 手写查询报错
  • join 表对不上
  • 表名忽大忽小

二、问题本质:这不是 Prisma bug,而是“数据库命名策略差异”

我们拆开看三层:


1️⃣ Prisma 层:模型是“逻辑名称”

model Permission {
  id   Int @id @default(autoincrement())
  name String @unique
}

👉 这里的 Permission 是:

  • 逻辑模型名

  • TypeScript 类型名

  • Prisma Client API 名

✔ 它只是“代码世界的名字”


2️⃣ Prisma 到数据库:通过 @@map 控制物理表名

如果你写:

model Permission {
  @@map("Permission")
}

👉 数据库就会是:

Permission

但如果你不写:

👉 Prisma 默认会做“平台适配转换”


3️⃣ MySQL 层:默认行为(关键坑点)

MySQL 在不同系统上行为不同:

系统 表名是否区分大小写
Linux ✅ 区分
Windows ❌ 不区分
Docker/Server 通常区分

而 MySQL 默认还有一个配置:

lower_case_table_names

三、真正导致“大小写混乱”的根本原因

你现在的问题通常来自三种情况:


❌ 情况 1:Prisma 自动映射

Prisma 有时会:

Permission → permission
Role → role

❌ 情况 2:MySQL 自动降级

导出 SQL 时:

Permission → permission

❌ 情况 3:手动建表 + Prisma 混用

比如你现在这种情况:

Permission(Prisma)
permission(历史遗留)

👉 直接导致双表混乱


四、最危险的后果(你已经踩过)

如果大小写混乱,会导致:


❌ 1. JOIN 永远 0 rows

JOIN Permission p

但真实数据在:

permission

❌ 2. Prisma connect 失败

connect: [{ name: "xxx" }]

👉 查错表 = 连接失败


❌ 3. 外键直接报错

Cannot add or update child row

五、为什么 Prisma 不直接统一大小写?

因为 Prisma 设计目标是:

✔ 跨数据库兼容(PostgreSQL / MySQL / SQLite)

而不同数据库规则不同:

数据库 命名规则
PostgreSQL 全小写
MySQL 依赖系统
SQLite 不敏感

👉 所以 Prisma 不强制统一大小写


六、正确解决方案(重点)


✅ 方案 1:强制统一表名(推荐)

在 Prisma 明确写:

model Permission {
  @@map("Permission")
}

model Role {
  @@map("Role")
}

✔ 强制数据库保持一致


✅ 方案 2:全部改为小写(更推荐生产)

@@map("permission")
@@map("role")

👉 统一行业标准(推荐)


✅ 方案 3:彻底禁止混用(最重要)

必须做到:

❌ 不允许 Permission + permission 共存


🚨 清理 SQL:

DROP TABLE permission;

或迁移:

INSERT INTO Permission (name)
SELECT name FROM permission;

七、最佳实践(企业级 RBAC 推荐)


✔ 表命名规范

permission
role
user
role_permission

👉 全部小写


✔ Prisma 写法

@@map("permission")
@@map("role")

✔ 外键表显式建模(避免隐式表)

model RolePermission {
  roleId Int
  permissionId Int
}

八、你这个项目的真实问题总结

你现在的问题不是 Prisma bug,而是:

❗ “隐式 join 表 + 大小写不统一 + 手动 SQL 混用” 三重冲突


九、一句话总结

Prisma 的 Model 名是逻辑层(大写),数据库表名是物理层(大小写敏感),如果不统一映射规则,就会导致 SQL / Prisma / join 全部错位。


十、最终建议(非常重要)

如果你要彻底解决:

✔ 做三件事:

  1. 统一表名(建议全部小写)

  2. 删除重复 permission / Permission

  3. Prisma 加 @@map 固定映射

  4. 不再手写 join table SQL


📌 如果你下一步要,我可以帮你做:

  • 🔥 直接帮你重构 RBAC(企业级标准)

  • 🔥 帮你清理现在数据库所有冲突表

  • 🔥 或帮你改成“完全不会踩坑的 Prisma 权限系统”


📎 本文部分内容借助 AI 辅助生成,并由作者整理审核。

连载10-Sub-agents 深度解析:从源码理解 Claude Code 的分身术

2026年5月23日 19:33

cover_09_subagents.png

Sub-agents 深度解析:从源码理解 Claude Code 的分身术

AI Coding 系列第 09 篇 · 多 Agent 编排


这篇文章讲到最后只有一句话:Sub-agent 不是一个人,是一套机械规则。 你在后面三节遇到的每个"为什么它会这样做",答案都不在 prompt 工程里——在 runAgent.ts 的几行 if 里。带着这句话往下读,这篇 9000 字的源码分析会变成它的验证过程。


你已经会用 Claude Code 完成单轮任务,也了解 Skill 和 Hook 如何定义知识和自动化规则。但当你面对一个需要"同时做三件事"的复杂项目——比如一边重构后端接口、一边更新文档、一边跑测试——你本能地想让 Claude"分身"去并行处理。

这篇文章从源码层面拆解 Sub-agents 的运行机制。不是教你"可以并行"这种显而易见的话,而是帮你理解:Claude Code 内部是怎么 fork 一个 Agent 的,Agent 之间的上下文隔离到底意味着什么,以及 worktree 隔离、权限继承、生命周期管理这些你在官方文档里找不到细节的东西。


一、先建立正确的心智模型

很多人把 Sub-agent 理解成"多线程"。这个类比有误导性。

更准确的说法是:Sub-agent 是一个独立的 LLM 会话,拥有自己的消息历史、工具集、中止控制器和文件状态缓存,但与父 Agent 共享同一个 AppState 状态树。

看一眼 runAgent.ts 中创建子 Agent 上下文的核心代码:

📂 展开源码:createSubagentContext 调用
// src/tools/AgentTool/runAgent.ts
const agentToolUseContext = createSubagentContext(toolUseContext, {
  options: agentOptions,
  agentId,
  agentType: agentDefinition.agentType,
  messages: initialMessages,
  readFileState: agentReadFileState,
  abortController: agentAbortController,
  getAppState: agentGetAppState,
  shareSetAppState: !isAsync,  // 同步 Agent 与父共享状态写入
  shareSetResponseLength: true,
})

关键细节:

  • readFileState 是从父 Agent 克隆的,不是共享引用。子 Agent 读文件时走自己的缓存,不会污染父 Agent 的文件视图。
  • abortController 对异步 Agent 是全新的(new AbortController()),对同步 Agent 是共享父 Agent 的。这意味着你 Ctrl+C 取消父 Agent 时,同步子 Agent 也会被取消,但后台运行的异步 Agent 不会。
  • shareSetAppState: !isAsync 这行很关键——同步 Agent 能直接写入父 Agent 的状态树,异步 Agent 则完全隔离。

这不是多线程,更像是 Unix 的 fork():创建时复制父进程的内存快照,之后各走各路。

理解这个模型之后,你就能回答一个更根本的问题——为什么必须用 Sub-agent 而不是在主对话里多写几个 prompt?

答案不在"聪明"或"更强",而在结构。主对话的上下文是线性追加的,500 行测试日志、200 行 grep 结果、一堆中间推理——这些信息对"当下执行"是必要的,但对"后续决策"是噪声。Claude Code 不会自动区分临时执行数据和长期决策记忆,默认全当作重要信息存着。

而 Sub-agent 是 Claude Code 里唯一一个结构上允许"执行完即丢弃"的东西。它的上下文窗口独立——噪声进去,结论出来,窗口销毁。主对话永远看不到中间过程。不是优化,是架构层面的隔离。

09_subagent_fork_model.png


二、何时用 Sub-agent:四个问题搞定决策

你不需要读完剩余 700 行源码分析再做决定。问自己四个问题就够了。

09_decision_matrix.png

问题一:主对话真的需要这些中间过程吗?

如果任务的输出超过 50 行,而你只关心里面不到 10 行的内容——用子代理。

  • 跑测试:300 行日志 → 你只需要"通过/失败,哪个挂了" → 信噪比 1%
  • 代码搜索:grep 返回 50 个匹配 → 你需要 3 个关键文件 → 信噪比 6%
  • 日志分析:1000 行错误 → 你需要 1 条根因判断 → 信噪比 0.5%

噪声留在主对话的不是"看着乱",是 token 膨胀。一次 npm test 输出 300 行 ≈ 4500 tokens——后续每轮对话都要重新发送这些噪声。子代理把这些吞下去,吐回 200 tokens 的精炼摘要。从 8800 tokens 压到 3700 tokens,主对话瘦身 58%。

记一条规则:如果你想让主对话记住什么,就别让不重要的东西进入它的上下文。 这就是 Sub-agent 唯一不可替代的价值——结构上允许"执行完即丢弃"。

问题二:子 Agent 需要继承父 Agent 的上下文吗?

  • 需要 → 用 Fork。省略 subagent_type,子 Agent 继承父 Agent 的对话历史和系统提示。共享 prompt cache,便宜。适合"帮我分担当前任务的一部分"。
  • 不需要 → 用 Named Agent。指定 subagent_type,子 Agent 从零开始,只看你写在 prompt 里的信息。适合"帮我做一件独立的事"。

Fork 和 Named Agent 不是高级/低级的区别,是两种通信模式。Fork 是"你继续做这个,我分个身帮你分担";Named Agent 是"你去把这件事做了,我不管你之前干了什么"。

问题三:子 Agent 的修改会污染我当前的编辑工作吗?

  • → 加 isolation: "worktree"。子 Agent 在独立的 git worktree 里工作,修改不碰你的文件。完成后无变更则自动清理,有变更则保留分支让你 review 后合并。
  • 不会(只读/搜索/分析等不写代码的任务)→ 不需要。

Worktree 的附加代价:每个 worktree 借用一个 git branch;node_modules 等大目录通过 symlink 共享(但如果子 Agent npm install 了新包,注意不要污染主仓库的依赖)。详见第五节。

问题四:这笔账划得来吗?

每个 Sub-agent 启动有 1-3 秒开销:克隆文件缓存、构建系统提示、加载 Skills、连接 MCP。同时,它的上下文隔离帮你省下几千到几万 tokens 的噪声搬运。

结论不是"Sub-agent 很贵"也不是"很值",而是——值不值取决于任务信噪比。低信噪比任务(跑测试、搜代码、分析日志)用 Sub-agent 绝对划算;高信噪比任务(直接的对话互动)不需要画蛇添足。

经验法则:

子任务预计耗时 决策
> 3 分钟 启动成本忽略不计,大胆用
30 秒 ~ 3 分钟 信噪比判断决定
< 30 秒 不值得,主对话直接做完

实战:怎么在对话中调用子 Agent

讲的都是"什么时候用",现在说"怎么用"。Claude Code CLI 里只能输入自然语言。 文章中出现的 Agent({subagent_type: "...", ...}) 是 Claude 内部的工具调用格式,不是让读者直接在终端敲的——Claude 读自然语言,帮读者生成这些调用。

你说自然语言 → Claude 解析意图 → Claude 内部生成 Agent() 工具调用 → 子 Agent 干活 → 结果展示给你。

# 触发内置 Explore Agent
帮我找一下项目中所有和 JWT token 验证相关的代码

# 触发你定义的自定义 Agent(如果 .claude/agents/ 里有 code-reviewer.md)
用 code-reviewer 审查 src/auth/ 的安全问题

# 触发 Fork(Claude 判断需要继承上下文时)
重构好了,帮我顺便写一下这三个函数的单元测试

# 流水线(Claude 顺序执行多个 Agent)
用 bug-locator 找到 token 验证失败的原因,然后让 bug-analyzer 分析根因

Claude 内部做的事:匹配你提到的名字(如 code-reviewer)到 .claude/agents/ 或内置 Agent → 生成 Agent() 工具调用(参数是 Claude 自己根据你的描述推断的)→ 子 Agent 启动 → 完成后直接把结果展示给你。你不会看到中间的 Agent({...}) 调用,只看到最终的文字回复。

如果想约束子 Agent 的行为(工具、权限、模型),要么在 .claude/agents/ 里预先配好(推荐),要么在自然语言里说清楚。话说得越具体,Claude 生成的调用参数越精确:

# 粗粒度(Claude 自己判断一切)
帮我审查代码

# 细粒度(你指定 Agent、范围、关注点)
用 code-reviewer 审查 src/auth/tokenValidator.ts,
重点关注硬编码密钥、缺少输入校验、auth 绕过风险,
用 sonnet 模型,最多调 20 轮工具

# 带 worktree 隔离(并行修改不要互相污染)
用 bug-fixer 修复 tokenValidator.ts:42-68 的竞态条件,
用 worktree 隔离,改完跑测试验证

Claude 会把"用 sonnet 模型"翻译成 model: "sonnet","最多调 20 轮工具"翻译成 maxTurns: 20。不是说自然语言万能——有些参数 Claude 可能理解偏差。关键的权限边界和工具白名单建议在 .claude/agents/ 配置里锁死,不要依赖自然语言。

常见疑问:如果同时有 code-review Skill 和 code-reviewer Sub-agent,Claude 选哪个?

源码里没有硬编码优先级。Claude 根据自己的判断二选一——它从系统提示里同时看到"可用 Skill 列表"和"可用 Agent 列表",靠任务特征自行裁量。简单规则性任务(如格式化输出)倾向 Skill;复杂多步骤、需要上下文隔离的任务倾向 Sub-agent。

但有一个非显而易见的耦合:Skill 的 frontmatter 里可以设 context: fork 这种情况下,Skill 的实际执行会被路由到 Sub-agent——Claude 表面在"调用 Skill",底层却启动了一个独立上下文的子 Agent。从这个角度看,Skill 和 Sub-agent 不是互斥选项——context: fork 的 Skill 就是用 Sub-agent 跑的 Skill。

调用后,后台的流程是透明的:

  1. Claude 把自然语言翻译成 Agent() 工具调用 → 源码根据 subagent_type(Claude 判断的)路由到对应 Agent 定义
  2. 创建独立的 LLM 会话——克隆文件缓存、构建系统提示、加载 Skills、连接 MCP
  3. 子 Agent 执行任务,它的工具调用和思考过程不会出现在你的对话里
  4. 完成后,只把最终的文字回复展示在你的对话中

你感知到的:子 Agent 执行期间终端可能显示它的工具调用(如果你开了详细输出),但它返回给你对话的内容只有最终的文字结果。500 行 grep 输出被子 Agent 吞掉了,你只看到"找到了 3 个相关文件,路径如下"。

(本文中的 Agent({...}) 代码示例展示的是 Claude 内部的工具调用格式,方便你理解参数含义。你不是在 CLI 里敲这些代码——这些是 Claude 在你的自然语言指令下生成的。)

下面走进源码,看这些机制具体怎么实现。


三、四种内置 Agent 类型:各有各的活法

09_agent_types_compare.png

Claude Code 不是只有一种 Sub-agent。打开 builtInAgents.ts,你会看到内置 Agent 的注册逻辑:

📂 展开源码:内置 Agent 注册
// src/tools/AgentTool/builtInAgents.ts
const agents: AgentDefinition[] = [
  GENERAL_PURPOSE_AGENT,
  STATUSLINE_SETUP_AGENT,
]

if (areExplorePlanAgentsEnabled()) {
  agents.push(EXPLORE_AGENT, PLAN_AGENT)
}

这段代码展示了本文重点关注的四种 Agent。完整源码中还有 CLAUDE_CODE_GUIDE_AGENT(回答 Claude Code 使用问题)和 VERIFICATION_AGENT(feature gate 控制的验证 Agent),以及 Coordinator Mode 下的动态 Agent 编排分支——它们各有专门的场景,不影响对核心四种的理解。

Explore Agent——只读搜索专家

Explore Agent 的系统提示开头就是一堵墙:

📂 展开源码:Explore Agent 的系统提示(只读限制)
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
...

它的 disallowedTools 直接禁掉了 AgentFileEditFileWriteNotebookEditExitPlanMode。这不是"建议"只读,而是工具级别的硬限制——Explore Agent 连尝试写文件的机会都没有。

注意这里的设计原则:安全感不是靠 prompt 劝说 Agent "请不要修改文件",而是从工具层把 Write 和 Edit 物理删掉。Agent 没有"自觉性"——唯一可依赖的,是它能调用哪些函数。这不是信任问题,是机械限制。

有两个值得注意的源码细节:

1. 它不带 CLAUDE.md:

📂 展开源码:omitClaudeMd 检查逻辑
// src/tools/AgentTool/runAgent.ts
const shouldOmitClaudeMd =
  agentDefinition.omitClaudeMd &&
  !override?.userContext

Explore Agent 设了 omitClaudeMd: true。原因是 Explore 只做搜索,commit 规范、PR 模板、lint 规则这些 CLAUDE.md 里的指令对它毫无意义。Anthropic 在代码注释里说这个优化"saves ~5-15 Gtok/week across 34M+ Explore spawns"——每周节省约 5-15 Gtok,覆盖 3400 万次以上 Explore 调用,每次省几千 token,累计节约量级惊人。

2. 它也不带 gitStatus:

📂 展开源码:gitStatus 省略逻辑
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
  baseSystemContext
const resolvedSystemContext =
  agentDefinition.agentType === 'Explore' ||
  agentDefinition.agentType === 'Plan'
    ? systemContextNoGit
    : baseSystemContext

Explore 和 Plan 不需要会话开始时的 git status 快照(最多 40KB)。如果它们需要 git 信息,会自己跑 git status 获取实时数据,而不是依赖可能已经过期的快照。

Plan Agent——只读架构师

Plan Agent 和 Explore 共享同一套只读限制,但角色定位不同。它的系统提示要求输出结构化的实施方案:

End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan

Plan Agent 用 model: 'inherit',继承父 Agent 的模型。Explore 对外部用户默认用 Haiku(追求速度),Plan 则需要和父 Agent 一样强的推理能力。

General-Purpose Agent——全能型选手

tools: ['*'] 意味着它能使用所有工具(写代码、跑测试、执行 bash 命令),是真正的"全能分身"。注意:当 fork 功能开启时,省略 subagent_type 触发的是 Fork 而非 General-Purpose(见下文 Fork 小节)。

📂 展开源码:General-Purpose Agent 定义
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
  agentType: 'general-purpose',
  tools: ['*'],     // 全部工具
  source: 'built-in',
  baseDir: 'built-in',
  // model is intentionally omitted - uses getDefaultSubagentModel()
  getSystemPrompt: getGeneralPurposeSystemPrompt,
}

Fork Agent——上下文继承的分身

Fork 的触发机制不是语义分析——是参数缺失时的默认路由:当 Claude 生成 Agent() 调用但省略了 subagent_type,且 fork feature gate 开启时,自动走 Fork 路径(AgentTool.tsx:322)。它的特征是继承父对话的完整上下文——你不需要在 prompt 里重复"我刚才重构了哪些文件",Fork 子 Agent 从对话历史里自己知道。

和 Named Agent 的区别一目了然:

你:重构 src/auth/ 的 token 验证逻辑,改了三个函数
Claude:完成
你:顺便帮我写一下这三个函数的单元测试吧
   → Claude 判断:子 Agent 需要知道"刚才重构了哪些函数"
   → 触发 Fork:子 Agent 从对话中直接知道,不需要你重复说

你:检查 src/auth/ 有没有安全问题
   → Claude 判断:审查独立于之前的对话
   → 使用 code-reviewer(Named Agent):从零开始,只看你给的信息

Fork 不能嵌套——Fork 子 Agent 不能再创建自己的子 Agent。多层编排的工作由主对话负责。

📂 展开源码:Fork 的定义、触发路由、消息构建和防递归机制
// src/tools/AgentTool/forkSubagent.ts
// Not registered in builtInAgents — used only when !subagent_type
export const FORK_AGENT = {
  agentType: 'fork',
  tools: ['*'],
  maxTurns: 200,
  model: 'inherit',
  permissionMode: 'bubble',
  getSystemPrompt: () => '',  // 继承父 Agent 的系统提示
}

触发路由(AgentTool.tsx:322):

const effectiveType = subagent_type ??
  (isForkSubagentEnabled() ? undefined : 'general-purpose')
// subagent_type 缺失 + fork 门开启 → Fork 路径

Fork 子 Agent 产生字节级相同的 API 请求前缀,共享 prompt cache 所以比新建 Agent 便宜。防递归靠 isInForkChild() 检测对话中的 <fork_boilerplate> 标记直接拒绝。

四种 Agent 的分工很清楚:Explore 找,Plan 想,General-Purpose 做。Fork 省略 subagent_type 即可触发——需要继承上下文时不加字段走 fork,独立任务时指定类型走 Named Agent。不能在 .claude/agents/ 里定义 fork 类型的自定义 Agent。


四、.claude/agents/ 自定义 Agent:你只需要关注四个字段

除了内置 Agent,你可以在 .claude/agents/ 目录下用 Markdown + YAML frontmatter 定义自己的 Agent。

先说不该做的事:把 Zod Schema 里所有 14 个字段背下来。你真正每次都要认真设计的只有四个——description(何时触发)、tools / disallowedTools(权限边界)、model(成本决策)。其余字段有需要时再查。

一个接近实战的配置示例(不是模板,是设计思路的载体):

---
name: code-reviewer
description: "Reviews code changes for quality, security, and consistency with project conventions. Use when you want a second opinion on code before committing."
model: inherit
permissionMode: dontAsk
tools:
  - Read
  - Glob
  - Grep
  - Bash
disallowedTools:
  - Write
  - Edit
  - NotebookEdit
skills:
  - code-review
hooks:
  SubagentStop:
    - hooks:
        - type: command
          command: "echo 'Code review completed at $(date)' >> .claude/review-log.txt"
maxTurns: 30
---

You are a code review specialist. Your job is to analyze code changes and provide actionable feedback.

Focus on:
- Security vulnerabilities (injection, auth bypass, data exposure)
- Performance issues (N+1 queries, unnecessary allocations, blocking calls)
- Error handling gaps (missing try-catch, unhandled promise rejections)
- Consistency with existing patterns in the codebase

Be specific: cite file paths and line numbers. Don't flag style issues unless they affect readability.

四个需要认真设计的字段:

  • description:不是你写给人看的注释——是 Claude 判断"何时自动调用这个 Agent"的唯一依据。写清楚做什么什么时候用。关键词 proactively 会鼓励 Claude 在合适的时机主动委派。
  • tools vs disallowedTools:白名单黑名单二选一,不要同时用。只读审查用 tools: [Read, Grep, Glob];需要大部分工具但排除个别危险的用 disallowedTools: [Write, Edit]。原则:最小权限——能用 Read 完成的就不要给 Edit。
  • model:不是越强越好。代码审查/分析推理 → sonnet;执行固定流程/模式匹配 → haiku;需要和主对话同等推理 → inherit。选错模型比选错工具更贵——Anthropic 的研究表明,升级模型的性能提升往往超过翻倍 token 预算的效果。
  • skills:Agent 不会自动继承主对话的 Skill。如果子 Agent 需要某个 Skill 的知识(如链路的 SLA 约束、历史事故记录),必须在 skills 字段显式列出,Skill 内容会在 Agent 启动时注入为 isMeta: true 的系统消息。

其余字段说明(有需要再看):

字段 用途 何时需要考虑
permissionMode 覆盖权限确认行为 异步 Agent 建议设 dontAsk
maxTurns 限制工具调用轮数 防止跑飞,建议 20-50
background 强制后台运行 长时间任务的非阻塞执行
isolation worktree 隔离 并行修改不同模块时启用
mcpServers Agent 专属 MCP 连接 需要访问特定外部服务
hooks Agent 生命周期的自动动作 SubagentStop 写日志等
initialPrompt 首轮额外注入的提示 给 Agent 额外的任务约束
memory 记忆作用域 跨会话共享知识

Agent 来源优先级

同名 Agent,后加载的覆盖先加载的:

// 覆盖链:内置 → 插件 → 用户级(~/.claude/agents/) → 项目级(.claude/agents/) → 企业管理策略
const agentMap = new Map<string, AgentDefinition>()
for (const agents of [builtIn, plugin, user, project, flag, managed]) {
  for (const agent of agents) {
    agentMap.set(agent.agentType, agent)  // 后写入覆盖先写入
  }
}

这意味着:你在 .claude/agents/ 里定义的 general-purpose Agent 会替换内置的通用 Agent。企业管理员可以通过策略设置强制覆盖所有 Agent 定义。

所以你应该怎么做:配置完 Agent 后,用一个简单任务测试 description 是否能正确触发。如果 Claude 该用的时候不用,大概率是 description 写得像"自我介绍"而不是"使用条件"。


五、Worktree 隔离:给 Agent 一个独立的代码沙箱

当你设置 isolation: "worktree" 时,子 Agent 会在一个独立的 git worktree 中工作。先理解概念:git worktree 让你在同一个仓库里同时 checkout 出多个分支到不同目录——每个目录像一个独立的仓库副本,有各自的 HEAD,但共享同一个 .git 目录。你不必为了在新分支上工作而 stash 当前修改。

09_worktree_architecture.png

本质上就是 fork() + chroot():共享同一个 .git,但每个 Agent 看到的文件系统是独立的隔离视图。

创建流程

📂 展开源码:Worktree 创建流程 (createAgentWorktree)
// src/utils/worktree.ts - createAgentWorktree
export async function createAgentWorktree(slug: string): Promise<{
  worktreePath: string
  worktreeBranch?: string
  headCommit?: string
  gitRoot?: string
}> {
  validateWorktreeSlug(slug)

  // 关键:使用 findCanonicalGitRoot 而不是 findGitRoot
  // 确保 Agent worktree 总是创建在主仓库的 .claude/worktrees/ 下
  // 而不是嵌套在某个会话 worktree 的 .claude/worktrees/ 里
  const gitRoot = findCanonicalGitRoot(getCwd())

  const { worktreePath, worktreeBranch, headCommit, existed } =
    await getOrCreateWorktree(gitRoot, slug)

  if (!existed) {
    await performPostCreationSetup(gitRoot, worktreePath)
  }
  return { worktreePath, worktreeBranch, headCommit, gitRoot }
}

创建后的自动化设置

performPostCreationSetup 做了一系列你手动操作很容易遗漏的事:

  1. 复制 settings.local.json:本地设置可能包含敏感配置,需要传播到 worktree
  2. 配置 git hooks 路径:让 worktree 复用主仓库的 .husky.git/hooks,避免 pre-commit hook 失效
  3. 符号链接大目录:根据 settings.worktree.symlinkDirectories 配置,symlink node_modules 等目录避免磁盘膨胀
  4. 复制 .worktreeinclude 指定的文件:gitignore 的文件(如 .env、build 产物)不在 worktree 中,但可以通过 .worktreeinclude 声明需要哪些

Worktree 的生命周期管理

Agent worktree 有一个优雅的"按需保留"机制:

📂 展开源码:Worktree 变更检查 (hasWorktreeChanges)
// 检查 worktree 是否有变更
export async function hasWorktreeChanges(
  worktreePath: string,
  headCommit: string,
): Promise<boolean> {
  // 检查 1: 有没有未提交的改动
  const status = await execFileNoThrowWithCwd(
    gitExe(), ['status', '--porcelain'], { cwd: worktreePath })
  if (statusOutput.trim().length > 0) return true

  // 检查 2: 有没有新的 commit
  const revList = await execFileNoThrowWithCwd(
    gitExe(), ['rev-list', '--count', `${headCommit}..HEAD`], { cwd: worktreePath })
  if (parseInt(revListOutput.trim(), 10) > 0) return true

  return false
}

如果子 Agent 完成后没有任何变更,worktree 会被自动清理。如果有变更(新 commit 或未提交的修改),worktree 和分支会保留,返回路径和分支名让你后续处理。

还有一个后台清理机制,定期扫描过期的临时 worktree:

📂 展开源码:临时 Worktree 清理模式 (EPHEMERAL_WORKTREE_PATTERNS)
const EPHEMERAL_WORKTREE_PATTERNS = [
  /^agent-a[0-9a-f]{7}$/,           // AgentTool 创建的
  /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/, // WorkflowTool 创建的
  /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/, // Bridge 创建的
]

只有匹配这些模式的 worktree 才会被自动清理——你手动通过 EnterWorktree 创建的 worktree(比如 feature-redesign)永远不会被误删。

Fork + Worktree 的组合

当 Fork 子 Agent 在 worktree 中运行时,会收到一条特殊的上下文通知:

📂 展开源码:Worktree 上下文通知 (buildWorktreeNotice)
// src/tools/AgentTool/forkSubagent.ts
export function buildWorktreeNotice(
  parentCwd: string, worktreeCwd: string,
): string {
  return `You've inherited the conversation context above from a parent
agent working in ${parentCwd}. You are operating in an isolated git
worktree at ${worktreeCwd} — same repository, same relative file
structure, separate working copy. Paths in the inherited context refer
to the parent's working directory; translate them to your worktree root.
Re-read files before editing if the parent may have modified them...`
}

这段提示告诉 Fork 子 Agent:你继承的上下文里的文件路径指向父 Agent 的工作目录,你需要把路径"翻译"到自己的 worktree 里。这是一个容易被忽略但非常关键的细节。

并行修改不同模块时启用 worktree,每个模块独立分支互不干扰。只读探索不需要。如果子 Agent 要在 worktree 里 npm install 新依赖,记得在 .worktreeinclude 里声明 .env 等被 gitignore 的关键文件。


六、权限模型:谁能做什么

Sub-agent 的权限控制是分层的,不是简单的"继承父 Agent 权限"。

权限模式覆盖

📂 展开源码:权限模式覆盖逻辑
// src/tools/AgentTool/runAgent.ts
const agentGetAppState = () => {
  const state = toolUseContext.getAppState()
  let toolPermissionContext = state.toolPermissionContext

  // Agent 定义的权限模式可以覆盖父 Agent 的
  // 但 bypassPermissions 和 acceptEdits 模式永远不会被覆盖
  if (
    agentPermissionMode &&
    state.toolPermissionContext.mode !== 'bypassPermissions' &&
    state.toolPermissionContext.mode !== 'acceptEdits'
  ) {
    toolPermissionContext = {
      ...toolPermissionContext,
      mode: agentPermissionMode,
    }
  }

  // 异步 Agent 不能显示权限弹窗——自动拒绝需要确认的操作
  if (shouldAvoidPrompts) {
    toolPermissionContext = {
      ...toolPermissionContext,
      shouldAvoidPermissionPrompts: true,
    }
  }
}

几条规则:

  • bypassPermissions(SDK 模式)和 acceptEdits 永远优先——子 Agent 不能收窄这两种宽松模式
  • 异步 Agent 设置 shouldAvoidPermissionPrompts: true,遇到需要用户确认的操作会自动拒绝
  • permissionMode: 'bubble' 是 Fork 的默认模式,权限请求会"冒泡"到父 Agent 的终端

工具过滤

📂 展开源码:工具过滤器 (filterToolsForAgent)
// src/tools/AgentTool/agentToolUtils.ts
export function filterToolsForAgent({ tools, isBuiltIn, isAsync }): Tools {
  return tools.filter(tool => {
    if (tool.name.startsWith('mcp__')) return true  // MCP 工具不受限
    if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) return false
    return true
  })
}

三层过滤:

  1. 所有 Agent 禁用的工具ALL_AGENT_DISALLOWED_TOOLS):比如 ExitPlanMode——子 Agent 不应该改变父 Agent 的计划模式
  2. 自定义 Agent 额外禁用的CUSTOM_AGENT_DISALLOWED_TOOLS):用户定义的 Agent 比内置 Agent 受限更多
  3. 异步 Agent 的白名单:后台运行的 Agent 只能使用一个限定的工具子集

MCP 工具(mcp__ 前缀)不受这些限制,始终可用。

allowedTools 的权限隔离

📂 展开源码:allowedTools 权限隔离
// 父 Agent 的 session-level 权限不会泄露到子 Agent
if (allowedTools !== undefined) {
  toolPermissionContext = {
    ...toolPermissionContext,
    alwaysAllowRules: {
      cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // 保留 SDK 级权限
      session: [...allowedTools], // 替换为子 Agent 自己的权限
    },
  }
}

注意这里的 cliArgsession 的区分:SDK 通过 --allowedTools 传入的权限(cliArg)是全局的,所有 Agent 都继承;而会话级别的权限(session)在子 Agent 创建时会被重置,防止父 Agent 运行时积累的权限无意间泄露给子 Agent。

自定义 Agent 首选白名单(tools),明确列出允许的工具,而不是依赖 disallowedTools 排除。异步 Agent 必须配合 permissionMode: 'dontAsk'bubble——否则需要确认的操作被静默拒绝,Agent 不知道原因就反复重试,看起来像卡住了。


七、Agent 的完整生命周期与 Hook 联动

Hook 不是独立于生命周期的外挂——SubagentStartSubagentStop 本身就是生命周期的两个关卡。先看 Hook 怎么嵌入,再看完整流程。

Hook 如何在生命周期中触发

在 Hooks 篇里讲过 SubagentStartSubagentStop 事件,这里从 Agent 源码看触发机制。

SubagentStart:启动前的注入

📂 展开源码:SubagentStart Hook 注入
// src/tools/AgentTool/runAgent.ts
// 执行 SubagentStart hooks 并收集额外上下文
const additionalContexts: string[] = []
for await (const hookResult of executeSubagentStartHooks(
  agentId, agentDefinition.agentType, agentAbortController.signal,
)) {
  if (hookResult.additionalContexts?.length > 0) {
    additionalContexts.push(...hookResult.additionalContexts)
  }
}

// 把 Hook 注入的上下文作为用户消息添加到初始对话中
if (additionalContexts.length > 0) {
  const contextMessage = createAttachmentMessage({
    type: 'hook_additional_context',
    content: additionalContexts,
    hookName: 'SubagentStart',
    ...
  })
  initialMessages.push(contextMessage)
}

SubagentStart Hook 可以向子 Agent 注入额外的上下文信息——比如团队编码规范的摘要、当前 Sprint 的约束条件、或者从 CI 系统拉取的最新构建状态。

Agent 自带的 Hooks

Agent 定义的 frontmatter 可以声明自己的 hooks,这些 hooks 会在 Agent 启动时注册为 session hooks,Agent 结束时自动清理:

📂 展开源码:Agent 专属 Hooks 注册/清理
// 注册 Agent frontmatter 中的 hooks
// isAgent=true 会把 Stop hooks 转换为 SubagentStop
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
  registerFrontmatterHooks(
    rootSetAppState,
    agentId,
    agentDefinition.hooks,
    `agent '${agentDefinition.agentType}'`,
    true,  // isAgent - converts Stop to SubagentStop
  )
}

// ... Agent 运行 ...

// 清理
finally {
  if (agentDefinition.hooks) {
    clearSessionHooks(rootSetAppState, agentId)
  }
}

isAgent = true 这个参数把 Agent frontmatter 里声明的 Stop hooks 自动转换成 SubagentStop hooks。因为子 Agent 完成时触发的不是 Stop(那是主会话结束时的事件),而是 SubagentStop

完整流程:从创建到销毁

一个 Sub-agent 从创建到销毁经历的完整流程:

启动阶段:

  1. 生成唯一的 agentIdcreateAgentId()
  2. 解析模型选择(Agent 定义 → 父 Agent 模型 → 默认模型)
  3. 如果启用了 Perfetto tracing,在追踪树中注册
  4. 克隆父 Agent 的 readFileState(文件缓存隔离)
  5. 构建上下文:Explore/Plan 去掉 CLAUDE.md 和 gitStatus
  6. 执行 SubagentStart hooks,收集额外上下文
  7. 注册 frontmatter hooks(Stop → SubagentStop 转换)
  8. 预加载 frontmatter 中声明的 Skills
  9. 初始化 Agent 专属的 MCP Servers
  10. 记录初始消息到 sidechain transcript

运行阶段:

📂 展开源码:生命周期:运行阶段 (query loop)
for await (const message of query({
  messages: initialMessages,
  systemPrompt: agentSystemPrompt,
  canUseTool: hasPermissionsToUseTool,
  toolUseContext: agentToolUseContext,
  querySource,
  maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
  // 转发 API metrics 到父 Agent 的显示
  // 记录每条消息到 sidechain transcript
  // 检测 max_turns_reached 信号
  yield message  // 流式输出给父 Agent
}

清理阶段(finally 块):

📂 展开源码:生命周期:清理阶段 (finally cleanup)
finally {
  await mcpCleanup()                          // 清理 Agent 专属 MCP 连接
  clearSessionHooks(rootSetAppState, agentId)  // 清理 session hooks
  cleanupAgentTracking(agentId)               // 清理 prompt cache 追踪
  agentToolUseContext.readFileState.clear()    // 释放文件缓存内存
  initialMessages.length = 0                  // 释放 fork 上下文消息
  unregisterPerfettoAgent(agentId)            // 释放 Perfetto 注册
  clearAgentTranscriptSubdir(agentId)         // 释放 transcript 映射
  // 清理 AppState.todos 中的孤儿条目
  // 杀死 Agent 启动的后台 bash 任务
  // 杀死 Agent 启动的 Monitor 任务
}

每一步清理都有明确的必要性。比如最后两步——如果不杀死 Agent 启动的后台 shell 循环(run_in_background 的任务),这些进程的父进程退出后会被 init 进程(PID=1)接管,变成"僵尸进程",在主会话退出后依然残留运行。


八、异步 Agent vs 同步 Agent:不只是"后台运行"

这不是简单的"加个 run_in_background: true"的区别。两种模式在架构上有本质不同。

fork() 的术语来说:同步 Agent 是 fork() + waitpid()——父进程阻塞等子进程结束;异步 Agent 是 fork() + detach——子进程独立运行,父进程继续干活。

09_sync_vs_async.png

维度 同步 Agent 异步 Agent
AbortController 共享父 Agent 的 独立的新实例
setAppState 共享父 Agent 的 隔离(通过 rootSetAppState 间接写入)
权限弹窗 可以显示 自动拒绝(shouldAvoidPermissionPrompts
工具集 完整(经过过滤) ASYNC_AGENT_ALLOWED_TOOLS 白名单
非交互模式 继承父 Agent 强制 isNonInteractiveSession: true
thinking 禁用 禁用
完成通知 直接返回结果 通过 enqueueAgentNotification

一个容易踩坑的点:异步 Agent 用 bubble 权限模式时,权限请求会冒泡到父 Agent 的终端,看起来像是同步的权限请求,但其实来自一个后台 Agent。这在同时运行多个异步 Agent 时可能造成困惑。

还有一个更隐蔽的坑:Agent 被静默拒绝后,不会告诉你"我卡在权限上了"。它只知道"操作失败了",然后用同样的方式重试。

所以你应该怎么做:短任务用同步(能直接看输出),长任务(>2 分钟)用异步(不阻塞主对话)。异步 Agent 启动前确保配了 permissionMode: 'dontAsk'bubble,并限定工具白名单——否则背景 Agent 会因权限不足静默失败,反复重试你也不知道为什么。


九、实战案例:基于源码理解的正确用法

本节中的 Agent({...}) 示例是 Claude 内部生成的工具调用格式,展示参数含义。CLI 中实际输入的是自然语言——Claude 帮你翻译成这些调用。

下面四个案例从简单到复杂递进:单一 Agent 审查 → 探索+实现串行流水线 → 多 Agent worktree 并行重构 → 影响面分析的事前拦截。

案例 1:并行代码审查

最基础的用法——四个完全独立的只读任务,并行执行。你要审查一个大 PR,涉及四个模块。每个模块的审查完全独立——审查 auth 的结果不影响审查 payment 的判断。

# 你在 CLI 里说:
用 code-reviewer 同时审查 src/auth/、src/payment/、src/order/、src/user/
四个模块的最新改动,每个模块独立审查,汇总成一份安全报告。

Claude 内部会把这一句话拆成四个并行的 Agent({subagent_type: "code-reviewer", ...}) 调用,四个审查 Agent 同时启动,各自只读分析自己负责的模块。

为什么这里必须用 Named Agent 而不是 Fork?因为审查 Agent 不需要知道你之前和 Claude 聊了什么——它只需要知道"去读哪几个文件"。Named Agent 从零开始,干净;Fork 继承你的对话历史,多余。

案例 2:探索 + 实现的流水线

案例 1 是"四个任务互不依赖"的并行模式。但现实中有很多任务是串行依赖的——先探索再实现,后一步需要前一步的输出。

# ❌ 错误:你说"帮我把 auth token 验证改成用 JWT,同时探索一下现在怎么实现的"
# → Claude 可能并行启动搜索和实现 → 实现 Agent 不知道搜索的发现

# ✅ 正确:
# 第一步:先搜索
用 Explore 找到项目中所有和 auth token 验证相关的实现,返回文件路径和函数名。

# 第二步:拿到搜索结果后,基于结果去改
# Claude 返回:tokenValidator.ts:42 用自定义 HMAC,session.ts:18 管理令牌生命周期
基于刚才 Explore 的结果,用 general-purpose Agent 把 tokenValidator.ts:42
的 HMAC 验证改成 JWT,同时更新 session.ts:18 的令牌生命周期逻辑。
# 注意这里 Claude 继承了对话上下文(Fork),知道 Explore 返回了什么

案例 3:Worktree 隔离的并行重构

案例 2 是串行流水线。现在回到并行——但这次每个 Agent 都会改文件,不再是只读。四模块重构可以并行,但需要各自独立的分支,互不污染。

# 你在 CLI 里说:
用四个 Agent 并行重构 user、product、order、payment 模块,
都改成 repository 模式。每个 Agent 用 worktree 隔离,
在自己的 git 分支上改。完成后告诉我各自的分支名。

Claude 内部给四个 Agent 各加 isolation: "worktree"。完成后每个 Agent 的改动在各自的临时分支上——你可以逐个 git diff 审查,不满意的直接删分支。四个重构互不干扰,也不用 stash 你当前的工作。

案例 4:影响面分析——堵住"正确代码、错误后果"的漏洞

前三个案例关注的是"怎么做"。案例 4 关注的是"该不该做"——用 Agent 在代码动工之前完成安全检查。

一个真实线上事故:开发者让 AI 对存量系统做功能迭代。代码本身没 bug,逻辑完全正确。上线后用户端 7 秒拿不到返回结果——新加的数据库查询增加了约 200ms 延迟,压垮了一个只剩 500ms 余量的 SLA 链路。

根因不是代码质量——是设计阶段缺少影响面分析

# 你说:
我准备重构 src/auth/tokenValidator.ts 的令牌验证逻辑。
先用 impact-analyzer 检查这个改动会影响哪些调用链,有没有 SLA 风险。

Claude 启动 impact-analyzer——这个 Agent 通过 skills: ["chain-knowledge"] 预加载了链路拓扑和 SLA 约束,能追踪每一层调用关系。它返回的分析报告会告诉你:这个改动会影响订单服务和支付回调链,SLA 余量只剩 300ms,你的改动可能让端到端延迟超限。

只有当影响面分析通过后,才启动修改 Agent。 这个流程把 Sub-agent 从一个"事后审查"的辅助角色,升级成了"事前拦截"的工程防线——不是代码写好后再检查,而是代码还没写就先堵漏洞。

流水线中的交接契约

案例 2 展示了一条串行流水线——Explore 找 → General-Purpose 改。当流水线拉长到三四个阶段时,上下游之间需要交接契约(Handoff Contract):上游为下游准备的结构化信息,让下游不需要重复任何搜索就能开始自己的分析。

反面教材:Bug Locator 输出"bug 可能在 auth 模块里" → Analyzer 收到后不得不自己又搜了一遍 → 流水线形同虚设。合格交接至少包含:具体文件路径、函数名、行号范围、搜索证据(搜过什么、排除了什么)、为什么怀疑这个位置。

扩展视角:从子代理到 Agent Teams

本文的 Sub-agent 有一个硬约束:子代理只能向主对话汇报,不能互相通信。 打破这个限制的是 Claude Code 的实验性功能 Agent Teams——下篇详解。


十、常见失败模式与源码级诊断

每个失败模式背后都有一个被源码证实了的心理误判。知道"为什么掉坑"比知道"坑在哪"更有用。

失败模式 1:Agent 消耗 token 却不返回有用结果

症状:Agent 运行了很久,做了很多工具调用,最终报告里信息很少——像是做了一大堆工作但没有总结。

心理根因:你以为 Agent 会"自然地"在最后做总结。LLM 没有总结本能——它只是在生成下一个 token。如果最后一轮恰好是工具调用,它不会"觉得"自己需要再补一段文字总结。

源码级原因finalizeAgentTool 优先提取最后一条 assistant 消息中的 text block(agentToolUtils.ts:301-303)。如果为空,会反向遍历所有历史 assistant 消息找第一个有 text 的(agentToolUtils.ts:307-315)——这个 fallback 能兜住一部分情况,但当 fallback 命中的是一条中间过程的思考而不是最终总结时,仍然拿不到有用的结果。根源还是 LLM 本身没有总结本能,以工具调用结束时不会自觉补一段文字。

解决方案:在 Agent 的 prompt 里明确要求"最后一条消息必须是文字总结,不要以工具调用结束"。不是你提示写得不够好——是提取逻辑本身只看最后一条消息。

失败模式 2:异步 Agent 被权限请求卡住

症状:异步 Agent 看起来卡住了,没有错误信息,没有进度,就像"死掉了"。

心理根因:你以为"后台运行 = 自动获得所有权限"。实际上后台运行的真相是"不能弹窗问你 → 自动拒绝 → Agent 不理解为什么被拒 → 重试 → 再次被拒 → 无限循环"。Agent 不会告诉你"我被权限卡住了",因为它的上下文里只有"操作失败了"。

源码级原因:当 shouldAvoidPermissionPrompts 为 true 时,需要权限确认的操作会被自动拒绝。Agent 不理解"拒绝"和"失败"的区别,继续用相同方式重试。

解决方案

  • 给异步 Agent 配置 permissionMode: 'dontAsk' 加上明确的 allowedTools(治本)
  • 或者用 permissionMode: 'bubble' 让权限请求冒泡到你的终端,但多 Agent 并行时一堆弹窗会让你困惑(治标)

失败模式 3:Fork 子 Agent 试图再 fork

症状:Fork 子 Agent 的对话突然终止,没有输出,也没有错误提示。

心理根因:你以为 Fork 就是"一个普通的 Agent,可以再调 Agent"。但 Fork 的本质是"克隆了主对话的上下文,带上防递归标记"。它的设计意图就是"只执行,不分发"——分发的责任在主对话。

源码级原因isInForkChild() 检测到对话中的 <fork_boilerplate> 标记,拒绝了 fork 请求。这不是 bug——所有编排必须由主对话完成,子 Agent 不能嵌套。

解决方案:Fork 子 Agent 收到的 boilerplate 里已经说了"Do NOT spawn sub-agents; execute directly"。如果你的任务确实需要多层 Agent 协作,用 Named Agent 而不是 Fork——让主对话作为唯一的编排者逐阶段调用。

这三个失败模式的共同根因:你把 Agent 当成了,但源码里它是一套机械规则。它不会"觉得该总结了"、"理解权限为什么被拒"、"知道不该再 fork"。每当 Agent 的行为不如预期,第一反应不是改进 prompt,而是去查对应的源码逻辑——通常答案就在几行代码里。


本篇实践任务

任务 1:解剖你项目中的 Agent 调用

在一个中等复杂度的项目上,让 Claude Code 做一个涉及搜索 + 修改的任务(比如"找到所有硬编码的 API URL 并替换为环境变量")。观察它是否主动使用了 Sub-agent,用的是哪种类型,prompt 是怎么写的。对比它的选择和你的直觉。

任务 2:写一个自定义 Agent 配置

.claude/agents/ 下创建一个只读的代码审查 Agent,配置 disallowedToolspermissionModemaxTurns。然后用它审查你最近的一次 commit,观察它的行为是否被配置正确约束了。

任务 3:测试 Worktree 隔离

对一个有测试的项目,启动两个 isolation: "worktree" 的 Agent 并行修改不同模块。完成后检查:各自的 worktree 分支是否独立?git log 是否只包含各自模块的修改?合并时是否有冲突?


下篇预告

第 10 篇:Agent Teams——当子 Agent 开始互相说话

本文讲的 Sub-agent 有一个硬约束:子 Agent 只能向主对话汇报,不能互相通信。而 Claude Code 的实验性功能 Agent Teams 打破了这个限制——Teammates 可以直接发消息、互相挑战结论、共享发现。下一篇讲 Agent Teams 的源码实现和四种核心协作模式:竞争假设、分层评审、模块化开发、规划审批。


AI Coding 系列持续更新。Sub-agent 不是让 Claude 做更多,而是让它记更少——噪声隔离有边界,编排决策有框架。

一个 `#[uniffi::export]`,把 Rust 接进 React Native

作者 红尘散仙
2026年5月19日 21:50

一篇偏教程向的 uniffi-bindgen-react-native 入门。适合已经有 Rust core、正在做 React Native / Expo 移动端,并且不想手写 Swift / Kotlin / C++ 胶水的人。

这是 SwarmNote 跨端架构系列的第三篇。上一篇讲了 tauri-specta 如何让 Tauri 的 invoke 有类型;这一篇接着讲移动端:React Native 怎么调用同一份 Rust core。

先说故事

桌面端的问题解决了:Tauri 的 invoke("xxx") 不再靠手写类型硬撑,tauri-specta 帮我们生成了 commands.xxx()events.xxx.listen()

然后移动端来了。

SwarmNote Mobile 选择了 React Native / Expo。原因在第一篇讲过:Tauri mobile 能跑,但移动端文件系统、权限、手势、键盘、安全区这些东西,RN/Expo 生态更顺手。

可是换成 RN 以后,新的问题出现了:

Rust core 已经写好了,React Native 怎么调用它?

你当然可以手写 Swift、Kotlin、C ABI、C++ Turbo Module,再手写 TypeScript 类型。能做,但很快就会变成另一种“跨端重复劳动”。

我们真正想要的是:

#[uniffi::export]
pub async fn open_workspace(path: String) -> Result<WorkspaceInfo, FfiError> {
    // Rust 做真正的事
}

RN 侧:

const workspace = await appCore.openWorkspace(path);

这就是 uniffi-bindgen-react-native 要解决的问题。

它是什么

一句话:

uniffi-bindgen-react-native 基于 Mozilla UniFFI,把 Rust API 自动生成成 React Native 可以调用的 TypeScript + C++ JSI / Turbo Module 绑定。

运行时链路是:

flowchart LR
    TS["TypeScript / React Native"]
    Hermes["Hermes JSI"]
    CPP["generated C++ binding"]
    Rust["Rust crate"]

    TS --> Hermes --> CPP --> Rust

    style Rust fill:#fff4cc,stroke:#d97706,stroke-width:2px

和桌面端的对应关系:

桌面 Tauri 移动 React Native
#[tauri::command] #[uniffi::export]
tauri-specta 生成 bindings.ts uniffi-bindgen-react-native 生成 TS + C++
WebView IPC Hermes JSI / Turbo Module
tauri_specta::Event callback interface / foreign trait
Tauri 注入 AppHandle / State Rust Object 自己持有状态

这篇不讲所有底层原理,只讲怎么搭起来、怎么写 API、怎么在 RN 里用。

你需要准备什么

基础工具:

# Rust 工具链
rustup --version
cargo --version

# C++ 构建工具
cmake --version
ninja --version

Android:

rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
cargo install cargo-ndk

iOS:

xcode-select --install
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios

如果你用 Expo,要记住一句话:

有原生 Rust Turbo Module 后,不能再用 Expo Go,需要 development build。

从零搭一个绑定包

通常做法是先创建一个 React Native library,再让 uniffi-bindgen-react-native 接管其中的 C++/TS 绑定。

1. 用 builder-bob 生成脚手架

npx create-react-native-library@latest my-rust-lib

交互选项可以这样选:

Library type: Turbo module
Languages: C++ for Android & iOS
Example app: Vanilla

进入项目:

cd my-rust-lib
yarn

为什么要选 C++?因为 uniffi-bindgen-react-native 会生成 C++ JSI 绑定,Turbo Module 壳需要这层。

2. 安装 uniffi-bindgen-react-native

yarn add uniffi-bindgen-react-native

或者在 pnpm 项目里:

pnpm add uniffi-bindgen-react-native

然后在 package.json 里加脚本:

{
  "scripts": {
    "ubrn:ios": "ubrn build ios --and-generate && (cd example/ios && pod install)",
    "ubrn:android": "ubrn build android --and-generate",
    "ubrn:web": "ubrn build web",
    "ubrn:checkout": "ubrn checkout",
    "ubrn:clean": "rm -rfv cpp/ android/CMakeLists.txt android/src/main/java android/*.cpp ios/ src/Native* src/index.*ts* src/generated/"
  }
}

ubrnuniffi-bindgen-react-native 的 CLI。

3. 清掉 builder-bob 原模板

builder-bob 会生成一套默认 C++ 示例。接入 Rust 以后,这些通常不需要:

yarn ubrn:clean

Windows / pnpm 项目可以按自己习惯改成 del-cli 或 node 脚本。SwarmNote Mobile 里就额外有一个 ubrn:fix 脚本,用来修正生成后的工程细节。

配置 ubrn.config.yaml

项目根目录创建:

---
rust:
  directory: ./rust
  manifestPath: Cargo.toml

bindings:
  cpp: cpp/generated
  ts: src/generated

noOverwrite:
  - "*.podspec"
  - package.json
  - android/build.gradle

如果 Rust 代码来自远程仓库,可以写:

rust:
  repo: https://github.com/user/my-rust-crate.git
  branch: main
  manifestPath: crates/api/Cargo.toml

SwarmNote Mobile 是 monorepo / 本地目录模式:

rust:
  directory: ./rust/mobile-core
  manifestPath: Cargo.toml

bindings:
  cpp: cpp/generated
  ts: src/generated

noOverwrite:
  - android/build.gradle
  - package.json
  - SwarmnoteCore.podspec

manifestPath 指向的 Rust crate 要能编译成 native library。通常在 Cargo.toml 里要有类似:

[lib]
crate-type = ["staticlib", "cdylib", "rlib"]

具体保留哪些 crate type,取决于你的 iOS / Android / workspace 配置。经验上,移动端绑定 crate 最好独立出来,不要直接把 uniffi 宏撒进共享 core。

构建和生成

常用命令:

# iOS:编译 Rust + 生成绑定 + pod install
yarn ubrn:ios

# Android:编译 Rust + 生成绑定
yarn ubrn:android

# 只拉取远程 Rust 代码
yarn ubrn:checkout

开发时可以只编某个架构,加快速度:

ubrn build ios --sim-only --and-generate
ubrn build android --targets aarch64-linux-android --and-generate

生成后你会看到:

my-rust-lib/
├── cpp/generated/              # C++ JSI 绑定
├── src/generated/              # TypeScript 绑定
├── android/src/main/jniLibs/   # Android .so
└── ios/build/*.xcframework     # iOS framework

SwarmNote Mobile 的绑定包叫 react-native-swarmnote-core,核心脚本是:

{
  "scripts": {
    "ubrn:android": "ubrn build android --and-generate && pnpm ubrn:fix",
    "ubrn:android:release": "ubrn build android --and-generate --release -t arm64-v8a && pnpm ubrn:fix",
    "ubrn:ios": "ubrn build ios --and-generate && pnpm ubrn:fix",
    "ubrn:ios:release": "ubrn build ios --and-generate --release && pnpm ubrn:fix"
  }
}

写 Rust API

Rust crate 入口先放:

uniffi::setup_scaffolding!();

SwarmNote 的 mobile-core/src/lib.rs 大概是:

uniffi::setup_scaffolding!();

mod app;
mod error;
mod events;
mod keychain;
mod types;
mod workspace;

下面看常用 API 形态。

1. 导出函数

最简单:

#[uniffi::export]
pub fn greet(name: String) -> String {
    format!("Hello, {name}")
}

TS 侧:

const message = greet("SwarmNote");

2. Record:传值类型

没有方法、只是数据,用 Record

#[derive(Debug, Clone, uniffi::Record)]
pub struct DeviceInfo {
    pub peer_id: String,
    pub device_name: String,
    pub hostname: String,
}

生成到 TS 后字段会转成 camelCase:

type DeviceInfo = {
  peerId: string;
  deviceName: string;
  hostname: string;
};

SwarmNote 里会专门做 FFI 侧 DTO,比如:

#[derive(Debug, Clone, uniffi::Record)]
pub struct UniffiDeviceInfo {
    pub peer_id: String,
    pub device_name: String,
    pub hostname: String,
    pub os: String,
    pub platform: String,
    pub arch: String,
    pub created_at: String,
}

不要把数据库表、内部状态机字段一股脑暴露给 RN。FFI DTO 应该是 host 需要的 API,而不是 core 内部结构的截图。

3. Object:让 RN 持有 Rust 句柄

真实 app 里更常见的是对象:

use std::sync::Arc;

#[derive(uniffi::Object)]
pub struct AppCoreHandle {
    inner: Arc<AppCore>,
}

#[uniffi::export(async_runtime = "tokio")]
impl AppCoreHandle {
    #[uniffi::constructor]
    pub async fn new(app_data_dir: String) -> Result<Arc<Self>, FfiError> {
        let inner = AppCore::new(app_data_dir).await?;
        Ok(Arc::new(Self { inner }))
    }

    pub async fn start_network(&self) -> Result<(), FfiError> {
        self.inner.start_network().await?;
        Ok(())
    }
}

SwarmNote 真实代码里的设备级对象叫 UniffiAppCore

#[derive(uniffi::Object)]
pub struct UniffiAppCore {
    pub(crate) inner: Arc<AppCore>,
    pub(crate) event_bus: Arc<UniffiEventBusAdapter>,
}

它负责持有设备身份、配置、P2P 节点、devices DB 等长期状态。RN 启动时创建一次,放进 context/store 里。

生成后的异步 constructor 在 TS 侧通常是 create

const appCore = await UniffiAppCore.create(keychain, eventBus, appDataDir);
await appCore.startNetwork();

4. async:Rust Future 变 Promise

Rust:

#[uniffi::export(async_runtime = "tokio")]
impl WorkspaceHandle {
    pub async fn read_text(&self, rel_path: String) -> Result<String, FfiError> {
        self.inner.read_text(&rel_path).await.map_err(Into::into)
    }
}

TS:

const text = await workspace.readText("daily.md");

如果你的 core 用 Tokio,#[uniffi::export(async_runtime = "tokio")] 很重要。SwarmNote 的 SeaORM、文件 I/O、P2P 任务都在 tokio runtime 里,所以导出 impl 基本都这样标。

uniffi-bindgen-react-native 生成的 async 方法还支持 AbortSignal

const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);

await workspace.readText("daily.md", { signal: controller.signal });

5. Enum:状态和事件

简单状态:

#[derive(Debug, Clone, uniffi::Enum)]
pub enum NodeStatus {
    Stopped,
    Running,
    Error { message: String },
}

带数据的 enum 会生成 tagged union 风格。SwarmNote 事件就是这种:

#[derive(Debug, Clone, uniffi::Enum)]
pub enum UniffiAppEvent {
    DocFlushed { doc_id: String },
    ExternalUpdate { doc_id: String, update: Vec<u8> },
    FileTreeChanged { workspace_id: String },
    NodeStarted,
    NodeStopped,
    SyncProgress {
        workspace_id: String,
        peer_id: String,
        completed: u32,
        total: u32,
    },
}

一个实战坑:不要随意在 enum 中间插入新 variant。旧 TS 绑定可能按旧 tag 解码,导致事件字段错位。新增事件尽量放末尾。

RN 侧怎么用

生成文件通常在:

src/generated/

你可以在包的 src/index.ts 里统一 re-export:

export * from "./generated/mobile_core";

然后 app 里:

import {
  UniffiAppCore,
  type ForeignEventBus,
  type UniffiAppEvent,
} from "react-native-swarmnote-core";

class EventBus implements ForeignEventBus {
  emit(event: UniffiAppEvent): void {
    // 分发到 Zustand / Redux / event emitter
  }
}

const appCore = await UniffiAppCore.create(keychain, new EventBus(), appDataDir);
const info = appCore.deviceInfo();
const workspace = await appCore.openWorkspace(path);

注意:命名一般会从 Rust snake_case 变成 TS camelCase,例如:

pub async fn open_workspace(&self, path: String) -> Result<Arc<UniffiWorkspaceCore>, FfiError>

TS:

await appCore.openWorkspace(path);

事件:Rust 怎么通知 JS

Tauri 桌面端可以 app.emit()。RN 里没有 Tauri AppHandle,所以用 callback interface / foreign trait。

完整链路是这样的:

sequenceDiagram
    participant Core as swarmnote-core
    participant Adapter as UniffiEventBusAdapter
    participant Trait as ForeignEventBus
    participant RN as RN EventBus class
    participant Store as Zustand / Editor bridge

    Core->>Adapter: EventBus::emit(AppEvent)
    Adapter->>Adapter: map_event(AppEvent) -> UniffiAppEvent
    Adapter->>Trait: foreign.emit(UniffiAppEvent)
    Trait->>RN: JS callback emit(event)
    RN->>Store: switch event.tag, update stores

1. Rust 先定义一个 JS 要实现的 trait

SwarmNote 的事件桥在 mobile-core/src/events.rs

#[uniffi::export(with_foreign)]
pub trait ForeignEventBus: Send + Sync {
    fn emit(&self, event: UniffiAppEvent);
}

with_foreign 的意思是:这个 trait 由外部语言实现。生成到 TypeScript 后,会变成一个接口:

export interface ForeignEventBus {
  emit(event: UniffiAppEvent): void;
}

事件 payload 是一个 UniFFI enum:

#[derive(Debug, Clone, uniffi::Enum)]
pub enum UniffiAppEvent {
    DevicesChanged { devices: Vec<UniffiDevice> },
    NetworkStatusChanged { nat_status: String, public_addr: Option<String> },
    PairingRequestReceived {
        pending_id: u64,
        peer_id: String,
        os_info: UniffiOsInfo,
        method: UniffiPairingMethod,
        expires_at: SystemTime,
    },
    ExternalUpdate { doc_id: String, update: Vec<u8> },
    NodeStarted,
    NodeStopped,
}

2. constructor 接收这个 callback

UniffiAppCore::new 把 JS 传进来的 event_bus 接住:

#[uniffi::export(async_runtime = "tokio")]
impl UniffiAppCore {
    #[uniffi::constructor]
    pub async fn new(
        keychain: Arc<dyn ForeignKeychainProvider>,
        event_bus: Arc<dyn ForeignEventBus>,
        app_data_dir: String,
    ) -> Result<Arc<Self>, FfiError> {
        let event_bus = Arc::new(UniffiEventBusAdapter::new(event_bus));

        let inner = AppCoreBuilder::new(
            Arc::new(UniffiKeychainAdapter::new(keychain)),
            event_bus.clone(),
            app_data_dir,
        )
        .build()
        .await?;

        Ok(Arc::new(Self { inner, event_bus }))
    }
}

这里的关键是:RN 侧传进来的对象不是临时调用一次就结束,而是被 Rust adapter 包起来,交给 AppCoreBuilder。之后 core 里任何地方触发 EventBus::emit(...),都能一路回到 JS。

3. Rust adapter 把 core event 映射成 FFI event

共享 core 不认识 UniFFI,它只认识自己的 EventBus trait。移动端 wrapper 负责做投影:

pub(crate) struct UniffiEventBusAdapter {
    foreign: Arc<dyn ForeignEventBus>,
}

impl EventBus for UniffiEventBusAdapter {
    fn emit(&self, event: AppEvent) {
        self.foreign.emit(map_event(event));
    }
}

真实代码里还有一个 map_event(event),把 core 的 AppEvent 转成 UniffiAppEvent。比如 UUID 转成 string,chrono::DateTime 转成 SystemTime,内部设备类型转成 FFI DTO。

4. RN 侧实现 callback class

生成绑定后,RN 侧实现 ForeignEventBus

import {
  type ForeignEventBus,
  type UniffiAppEvent,
  UniffiAppEvent_Tags,
} from "react-native-swarmnote-core";

export class EventBus implements ForeignEventBus {
  emit(event: UniffiAppEvent): void {
    switch (event.tag) {
      case UniffiAppEvent_Tags.DevicesChanged:
        useSwarmStore.getState().setDevices(event.inner.devices);
        break;

      case UniffiAppEvent_Tags.NetworkStatusChanged:
        useSwarmStore.getState().setNetworkStatus({
          natStatus: event.inner.natStatus,
          publicAddr: event.inner.publicAddr ?? null,
        });
        break;

      case UniffiAppEvent_Tags.PairingRequestReceived: {
        const { pendingId, peerId, osInfo, method, expiresAt } = event.inner;
        useNotificationStore.getState().push({
          id: `pairing-${pendingId.toString()}-${Date.now()}`,
          type: "pairing-request",
          payload: {
            pendingId,
            peerId,
            deviceName: osInfo.name ?? osInfo.hostname,
            os: osInfo.os,
            platform: osInfo.platform,
            method: method.tag,
            expiresAt,
          },
          timestamp: Date.now(),
        });
        break;
      }

      case UniffiAppEvent_Tags.ExternalUpdate: {
        const active = getActiveEditorBridge();
        if (active === null) break;
        const { docId, update } = event.inner;
        if (active.docUuid !== docId) break;
        active.applyRemoteUpdate(new Uint8Array(update));
        break;
      }
    }
  }
}

这段代码的作用就是把 Rust 事件翻译成 RN 世界里的状态更新:设备列表进 store,配对请求进通知队列,远端 Yjs update 转给 WebView editor bridge。

5. 创建 AppCore 时把 callback 传进去

最后,在 RN 初始化核心对象时,把 callback 实例作为参数传给 Rust constructor:

import { Paths } from "expo-file-system";
import { UniffiAppCore, type UniffiAppCoreLike } from "react-native-swarmnote-core";
import { EventBus } from "./event-bus";
import { Keychain } from "./keychain";

let corePromise: Promise<UniffiAppCoreLike> | null = null;
let core: UniffiAppCoreLike | null = null;

export function initAppCore(): Promise<UniffiAppCoreLike> {
  if (corePromise !== null) return corePromise;

  corePromise = (async () => {
    const instance = await UniffiAppCore.create(
      new Keychain(),
      new EventBus(),
      Paths.document.uri,
    );
    core = instance;
    return instance;
  })();

  return corePromise;
}

这就是前端“传 callback”的地方。new EventBus() 实现了生成出来的 ForeignEventBus 接口,UniFFI 会把它降到 native handle,Rust 侧收到的是 Arc<dyn ForeignEventBus>

6. 回调链路小结

把上面的代码串起来就是:

RN: new EventBus()
  ↓ 作为 constructor 参数
UniffiAppCore::new(event_bus: Arc<dyn ForeignEventBus>)
  ↓ 包成 adapter
AppCoreBuilder::new(..., Arc<dyn EventBus>, ...)
  ↓ core 触发 AppEvent
UniffiEventBusAdapter::emit(AppEvent)
  ↓ map_event
ForeignEventBus.emit(UniffiAppEvent)
  ↓ 回到 JS
EventBus.emit(event)
  ↓
Zustand store / WebView editor bridge

经验规则:

  • callback 里不要做重活,快速转发到 store 后返回。
  • 不要持有 Rust 锁时调用 JS callback,容易死锁。
  • 事件 payload 尽量用 FFI-friendly DTO,不要暴露内部复杂类型。
  • JS callback 实例要和 core 一样长期存活;通常把 UniffiAppCore 做成 singleton,callback 跟着它一起活。
  • 如果事件里有 u64,TS 侧通常是 bigint,UI store 需要 Number(...) 时要确认范围安全。

错误处理

Rust 侧:

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum FfiError {
    #[error("invalid path: {0}")]
    InvalidPath(String),

    #[error("P2P node is not running")]
    NetworkNotRunning,

    #[error("invalid input ({field}): {reason}")]
    InvalidInput { field: String, reason: String },
}

导出函数返回:

pub async fn open_workspace(&self, path: String) -> Result<Arc<UniffiWorkspaceCore>, FfiError>

TS 侧是 rejected Promise。判断错误时,不要用普通 instanceof,用生成类型的 instanceOf()

try {
  await appCore.startNetwork();
} catch (e) {
  if (FfiError.NetworkAlreadyRunning.instanceOf(e)) {
    return;
  }
  throw e;
}

SwarmNote 没有直接给共享的 AppError derive uniffi::Error,而是在 mobile wrapper 里单独定义 FfiError,再 impl From<AppError> for FfiError。这样 swarmnote-core 不需要依赖 UniFFI,桌面端也不会被移动端绑定约束污染。

内存和生命周期

UniFFI Object 背后是真实 Rust 资源。JS 对象被 GC 时,Rust 端引用会被释放,但 GC 时机不可控。

轻量对象可以交给 GC;持有文件、数据库、网络、同步任务的对象,建议显式释放:

try {
  const workspace = await appCore.openWorkspace(path);
  // ...
  await workspace.close();
  workspace.uniffiDestroy?.();
} finally {
  // 确保切换 workspace 前旧资源释放
}

SwarmNote 的 UniffiWorkspaceCore 就有明确生命周期约定:切换工作区前先 close(),保证 dirty docs flush,再释放句柄。

也可以用 uniffiUse() 做 RAII 风格的临时对象管理:

const result = manager.uniffiUse((m) => {
  return m.doSomething();
});

常见类型映射

Rust TypeScript
String string
bool boolean
u8 / u32 / i32 number
u64 / i64 bigint
Vec<T> T[]
Vec<u8> ArrayBuffer
Option<T> `T undefined`
SystemTime Date
#[derive(uniffi::Record)] plain object
#[derive(uniffi::Object)] object handle / class
#[derive(uniffi::Enum)] enum / tagged union
#[derive(uniffi::Error)] typed thrown error

这里和 tauri-specta 有个差异:64 位整数通常是 bigint,前端不要随手和 number 混算。

多 crate 项目怎么组织

如果你已经有一个 Rust workspace,不建议直接在核心 crate 上到处加 UniFFI 宏。更推荐这样:

rust/
├── core/              # 平台无关核心逻辑
├── p2p/               # 网络模块
└── mobile-core/       # UniFFI wrapper crate

mobile-core 负责:

  • 包装 core 的对象和方法
  • 定义 FFI-friendly DTO
  • 把 core error 转成 FfiError
  • 把 core event 转成 UniffiAppEvent
  • 处理移动端 path、keychain、event bus 等 adapter

SwarmNote 就是这种思路:

TypeScript / RN
  ↓ generated bindings
mobile-core
  ↓ wrapper / adapter
swarmnote-core
  ↓
swarm-p2p-core / yrs / sea-orm

这和桌面端 Tauri 的分层是对称的:桌面 host 用 #[tauri::command] 包 core,移动 host 用 #[uniffi::export] 包 core。

常见坑

处理方式
Expo Go 跑不了 使用 development build
改 Rust 后 TS 没变化 重新跑 ubrn build ... --and-generate
u64 到 TS 变 bigint 前端按 bigint 处理,或在 wrapper 层转成安全 u32 / String
持有资源的 Object 只靠 GC 提供 close(),RN 侧配合 uniffiDestroy()
callback 里做重活 只转发事件,异步处理放到 RN store / queue
core 被 UniFFI 污染 单独建 wrapper crate,不在共享 core 里 derive UniFFI
enum 中间插 variant 新 variant 尽量追加到末尾

适合谁

适合:

  • 已经有 Rust core
  • RN 只是移动端 host
  • 需要数据库、文件系统、加密、P2P、同步协议等重逻辑
  • 想要生成 TypeScript 类型
  • 不想维护 Swift / Kotlin 两套桥

不太适合:

  • 只是普通 UI app
  • 没有 Rust core
  • 系统能力 Expo/RN 库已经完全覆盖
  • 必须使用 Expo Go

小结

uniffi-bindgen-react-native 的主线其实是:

  1. 用 builder-bob 建一个 RN Turbo Module 包。
  2. uniffi-bindgen-react-nativeubrn.config.yaml
  3. 在 Rust wrapper crate 里写 uniffi::setup_scaffolding!()
  4. #[uniffi::export]RecordObjectEnumError 描述 FFI API。
  5. ubrn build android/ios --and-generate 生成 TS + C++ + native library。
  6. RN 侧直接 import 生成 API,像调用普通 TS 对象一样调用 Rust。

如果上一篇的 tauri-specta 是“给 Tauri IPC 生成 TypeScript 契约”,那这一篇的 uniffi-bindgen-react-native 就是“给 React Native 生成 Rust 原生模块契约”。

SwarmNote 最终采用的方式是:

桌面端: Tauri + tauri-specta
移动端: React Native + uniffi-bindgen-react-native
共享核心: swarmnote-core

不是一套壳跑所有端,而是每个平台用适合自己的壳,真正复杂的核心逻辑留在 Rust。

系列文章:

延伸阅读

AI 为什么总喜欢写防御性代码?

作者 Moment
2026年5月19日 19:21

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

AI 生成代码时,经常会写出一种看起来很谨慎的风格:到处判断空值、到处给默认值、到处包 try/catch,读取环境变量时还特别喜欢加 trim() 和 fallback。

比如下面这种代码很常见:

const port = Number(process.env.PORT?.trim() || 3000);
const apiKey = process.env.API_KEY?.trim() || "";
const timeout = Number(process.env.TIMEOUT || 5000);

try {
  // do something
} catch (error) {
  console.error(error);
  return null;
}

它表面上很安全:空值兜住了、默认值给了、字符串也 trim 了,异常也 catch 了。但真实工程里,这类写法经常不是让系统更可靠,而是把本该暴露的问题悄悄藏起来。

尤其是读取环境变量时,AI 很容易自动加 trim()|| default?? default。因为它把环境变量当成不可信输入来处理,这个判断有一半是对的:环境变量确实来自运行环境,不是代码内部常量。但另一半很危险:不是所有配置都能被自动修正,也不是所有缺失都应该给默认值。

真正的问题不是 AI 写了防御性代码,而是它不知道防御应该放在哪里,哪些错误应该被兜底,哪些错误必须直接暴露。

AI 写防御性代码,本质是在弥补上下文缺失

人写代码时,通常知道很多隐藏前提:

  • 这个参数是不是已经被 DTO 校验过
  • 这个函数是不是只会在内部调用
  • 这个字段在数据库里是不是非空
  • 这个异常应该由上层统一处理,还是在当前函数里消化
  • 这个配置缺失时应该启动失败,还是可以使用默认值

AI 往往不知道这些前提。它只能看到局部代码片段,所以会倾向于选择一种局部看起来更稳的写法:多判断一点,多兜底一点,多 catch 一点。

于是它很容易写出这种代码:

if (!user) {
  return null;
}

if (!items?.length) {
  return [];
}

try {
  return await service.run();
} catch {
  return undefined;
}

这些代码在局部看起来不会崩,但在系统层面可能更糟。因为它把本来应该暴露的问题,改造成了一个看似正常的返回值。

比如 return null 可能掩盖了用户不存在、权限不足、数据库异常、调用参数错误等完全不同的问题。调用方拿到 null 以后,不知道该重试、提示用户、回滚事务,还是报警排查。

fail fast 的核心思想是:错误越早、越明确地暴露,越容易定位和修复。系统如果自动绕过错误,问题可能会在更深的链路里变成更隐蔽、更难排查的故障。

所以,AI 的防御性代码经常不是工程健壮,而是局部自保。

训练语料也在强化这种写法

AI 代码模型学到的不是某个项目的架构约束,而是大量公开代码、教程、问答社区、文档示例里的高频模式。

公开代码里有大量这样的写法:

const value = input || defaultValue;
const name = user?.profile?.name ?? "";
const port = process.env.PORT || 3000;

久而久之,模型会形成一种倾向:不确定时就加默认值,不确定时就加空值判断,不确定时就包一层 try/catch

但公开代码里也包含大量不安全、过时或不适合生产的写法。AI 生成的代码看起来很防御,不代表它真的安全。它可能只是学会了安全代码的外观,比如加了空值判断、日志和默认值,但没有理解业务契约、权限边界和失败语义。

这也是为什么我们不能只看代码有没有考虑异常,而要看它有没有把异常处理成正确的系统行为。

防御性代码应该出现在边界,而不是到处出现

防御性代码本身没有错,错的是位置不对。

真正需要防御的地方,通常是系统边界:

  • HTTP 请求参数
  • 表单输入
  • 上传文件
  • 第三方 API 返回值
  • Webhook payload
  • 环境变量
  • CLI 参数
  • 数据导入文件
  • 跨租户资源访问
  • 权限和角色判断

这些位置的数据来自外部,确实应该严格校验、解析、归一化和拒绝非法输入。

但在业务核心逻辑里,到处兜底反而会破坏系统契约。

比如这段代码看起来很稳:

async function getUserName(userId?: string) {
  if (!userId) {
    return "";
  }

  const user = await userRepository.findById(userId);

  return user?.name ?? "";
}

调用方拿到空字符串以后,根本不知道发生了什么:

  • userId 没传?
  • 是用户不存在?
  • 是数据库异常?
  • 是权限不够?
  • 是数据脏了?
  • 是代码调用错了?

更好的做法,是把失败语义区分清楚:

type GetUserNameResult =
  | { ok: true; name: string }
  | { ok: false; reason: "USER_NOT_FOUND" };

async function getUserName(userId: string): Promise<GetUserNameResult> {
  if (!userId) {
    throw new Error("userId is required");
  }

  const user = await userRepository.findById(userId);

  if (!user) {
    return { ok: false, reason: "USER_NOT_FOUND" };
  }

  return { ok: true, name: user.name };
}

这里的重点不是少写防御代码,而是让每一种失败都有明确含义。参数错误直接抛出,业务上可预期的不存在用结构化结果表达,系统异常交给上层统一处理。

这才是工程上的防御,而不是把所有错误都变成空字符串、nullundefined

读取环境变量时,AI 为什么喜欢加 trim

环境变量确实是边界输入。它来自运行环境,不是代码内部定义的常量。

Twelve-Factor App 的配置原则 建议把不同部署之间会变化的配置放到环境变量里,比如数据库连接、外部服务凭证、每个部署不同的主机名等。这样配置可以和代码分离,不同环境也能使用同一份代码。

Node.js 文档也说明,环境变量最终会进入 process.env,并以字符串形式被读取。也就是说,0truefalse、JSON 字符串这些值,在进入应用后都不是数字、布尔值或对象,而是字符串。

所以 AI 看到下面这种代码时:

const port = process.env.PORT;
const enableCache = process.env.ENABLE_CACHE;

它会本能地觉得这里不安全,因为:

  • 值可能不存在
  • 值一定是字符串
  • 值可能包含空格
  • 值可能需要转换成数字、布尔值、URL 或枚举
  • 值可能来自 .env、Docker、Kubernetes、CI 或部署平台

于是它很容易生成:

const port = Number(process.env.PORT?.trim() || 3000);

这里的 trim() 不是完全没道理。它的潜台词是:我先把配置值前后的意外空格去掉,避免部署时因为复制粘贴多了空格导致解析失败。

在某些配置上,这样做是合理的,比如:

const nodeEnv = process.env.NODE_ENV?.trim();
const databaseUrl = process.env.DATABASE_URL?.trim();
const redisUrl = process.env.REDIS_URL?.trim();

但问题是,trim() 不能无脑加。配置值不是普通输入框文本,有些值的空白字符可能本身就是内容的一部分。

trim 最大的问题,是它可能改变配置语义

对于普通枚举、URL、端口号,去掉前后空格通常没问题。

但对于某些值,空白字符可能就是值的一部分,比如:

  • 密码
  • Token
  • HMAC secret
  • 私钥
  • 多行证书
  • Base64 内容
  • 某些第三方平台生成的密钥

如果 AI 写成这样:

const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";
const privateKey = process.env.PRIVATE_KEY?.trim();

这里至少有两个问题。

第一,trim() 可能改变 secret 的真实值。很多 secret 前后空格不是常见需求,但配置加载器不应该擅自修改它。更稳的做法是:如果不允许前后空格,就校验并报错,而不是悄悄帮它修。

第二,默认值 "secret" 非常危险。生产环境里密钥缺失时,系统应该启动失败,而不是自动使用一个弱默认值继续运行。

更合理的策略,是按配置类型分类处理:

function requireEnv(name: string): string {
  const value = process.env[name];

  if (value === undefined || value === "") {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

function requireTrimmedEnv(name: string): string {
  const value = requireEnv(name);
  const trimmed = value.trim();

  if (trimmed.length === 0) {
    throw new Error(`Environment variable ${name} cannot be blank`);
  }

  return trimmed;
}

function requireSecretEnv(name: string): string {
  const value = requireEnv(name);

  if (value !== value.trim()) {
    throw new Error(
      `Environment variable ${name} contains leading or trailing whitespace`,
    );
  }

  return value;
}

这里的区别很关键:

  • 普通配置可以 trim
  • secret 不要偷偷 trim
  • 如果 secret 不允许前后空白,就直接失败
  • 不要把配置错误自动修成另一个值

这才是真正的防御性代码。它不是帮系统圆过去,而是在错误进入业务逻辑之前把它拦下来。

默认值也是 AI 最容易滥用的地方

AI 读取环境变量时,也很喜欢写默认值:

const port = Number(process.env.PORT || 3000);
const databaseUrl = process.env.DATABASE_URL || "postgres://localhost:5432/app";
const jwtSecret = process.env.JWT_SECRET || "secret";
const enableDebug = process.env.ENABLE_DEBUG || false;

这类写法看起来方便,但它把三种完全不同的配置混在了一起:

  • 可以有默认值的配置
  • 本地开发可以默认、生产必须显式配置的配置
  • 绝对不能有默认值的配置

比如 PORT 默认成 3000 通常可以接受,因为它不是安全敏感配置。

DATABASE_URLJWT_SECRETOPENAI_API_KEYS3_SECRET_KEY 这类配置不能随便默认。缺失就应该启动失败。

否则生产环境可能出现非常隐蔽的问题:

  • 连接到了本地或错误数据库
  • 多个环境共用了同一个默认密钥
  • JWT 可以被弱密钥伪造
  • 第三方服务调用失败但应用启动成功
  • 线上流量进入了测试配置
  • 安全问题直到事故发生才暴露

更好的判断标准是:

可以默认:
- PORT
- LOG_LEVEL
- REQUEST_TIMEOUT_MS
- FEATURE_FLAG 默认关闭
- 分页大小
- 非生产环境 mock 开关

不应该默认:
- DATABASE_URL
- JWT_SECRET
- SESSION_SECRET
- API_KEY
- S3_SECRET_KEY
- ENCRYPTION_KEY
- OAUTH_CLIENT_SECRET
- WEBHOOK_SECRET

默认值不是不能用,而是只能用于缺失也不会破坏安全和数据正确性的配置。

|| default 经常比看起来更危险

AI 很喜欢写:

const timeout = Number(process.env.TIMEOUT_MS) || 5000;

这个写法有一个隐藏问题:它会把所有 falsy 值都当成缺失。

比如:

Number("0") || 5000;

结果是 5000,不是 0

如果 0 在业务里代表禁用超时、关闭重试、不限制数量,这个默认值就会悄悄改变行为。

更好的写法是先判断是否缺失,再解析:

function optionalIntEnv(name: string, defaultValue: number): number {
  const raw = process.env[name];

  if (raw === undefined || raw.trim() === "") {
    return defaultValue;
  }

  const value = Number(raw);

  if (!Number.isInteger(value)) {
    throw new Error(`Environment variable ${name} must be an integer`);
  }

  return value;
}

const timeoutMs = optionalIntEnv("REQUEST_TIMEOUT_MS", 5000);

这样至少能区分三种情况:

  • 没配置:使用默认值
  • 配了非法值:启动失败
  • 配了合法值:使用配置值

AI 经常把这三种情况混在一起,所以代码看起来短,实际风险更高。

环境变量应该集中读取、集中校验、启动时失败

环境变量不要散落在业务代码里。

不推荐这样写:

export async function callModel(prompt: string) {
  const apiKey = process.env.OPENAI_API_KEY?.trim() || "";

  if (!apiKey) {
    return null;
  }

  // ...
}

这会带来几个问题:

  • 配置错误运行到某个分支才暴露
  • 每个地方都有一套解析规则
  • 有的地方 trim,有的地方不 trim
  • 有的地方默认,有的地方抛错
  • 测试和生产行为不一致
  • 类型仍然是 string | undefined

更推荐在应用启动时集中解析:

type AppConfig = {
  nodeEnv: "development" | "test" | "production";
  port: number;
  databaseUrl: string;
  jwtSecret: string;
  requestTimeoutMs: number;
};

function parseNodeEnv(): AppConfig["nodeEnv"] {
  const value = process.env.NODE_ENV?.trim() || "development";

  if (!["development", "test", "production"].includes(value)) {
    throw new Error(`Invalid NODE_ENV: ${value}`);
  }

  return value as AppConfig["nodeEnv"];
}

function requireTrimmedString(name: string): string {
  const value = process.env[name];

  if (value === undefined) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  const trimmed = value.trim();

  if (trimmed.length === 0) {
    throw new Error(`Environment variable ${name} cannot be empty`);
  }

  return trimmed;
}

function requireSecret(name: string): string {
  const value = process.env[name];

  if (value === undefined || value.length === 0) {
    throw new Error(`Missing required secret: ${name}`);
  }

  if (value !== value.trim()) {
    throw new Error(`Secret ${name} contains leading or trailing whitespace`);
  }

  return value;
}

function optionalInteger(name: string, defaultValue: number): number {
  const value = process.env[name];

  if (value === undefined || value.trim() === "") {
    return defaultValue;
  }

  const parsed = Number(value);

  if (!Number.isInteger(parsed)) {
    throw new Error(`Environment variable ${name} must be an integer`);
  }

  return parsed;
}

export const config: AppConfig = {
  nodeEnv: parseNodeEnv(),
  port: optionalInteger("PORT", 3000),
  databaseUrl: requireTrimmedString("DATABASE_URL"),
  jwtSecret: requireSecret("JWT_SECRET"),
  requestTimeoutMs: optionalInteger("REQUEST_TIMEOUT_MS", 5000),
};

这个版本看起来比 AI 默认生成的代码更长,但它的工程收益很明确:

  • 配置只在启动时读取一次
  • 必填配置缺失时直接失败
  • 默认值只给低风险配置
  • secret 不会被偷偷修改
  • 数字、枚举、字符串都有明确解析规则
  • 业务代码不用再处理 process.env.xxx
  • 配置错误不会拖到运行中才暴露

这就是环境变量读取里真正合理的防御性代码。

使用 Zod,比到处手写 if 更稳定

如果项目里已经使用 Zod,可以把环境变量当成一个边界输入,用 Schema 统一校验。

import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z
    .enum(["development", "test", "production"])
    .default("development"),

  PORT: z
    .string()
    .optional()
    .transform((value) => {
      if (value === undefined || value.trim() === "") {
        return 3000;
      }

      const parsed = Number(value);

      if (!Number.isInteger(parsed)) {
        throw new Error("PORT must be an integer");
      }

      return parsed;
    }),

  DATABASE_URL: z
    .string()
    .trim()
    .min(1, "DATABASE_URL is required"),

  JWT_SECRET: z
    .string()
    .min(1, "JWT_SECRET is required")
    .refine((value) => value === value.trim(), {
      message: "JWT_SECRET must not contain leading or trailing whitespace",
    }),

  REQUEST_TIMEOUT_MS: z
    .string()
    .optional()
    .transform((value) => {
      if (value === undefined || value.trim() === "") {
        return 5000;
      }

      const parsed = Number(value);

      if (!Number.isInteger(parsed) || parsed <= 0) {
        throw new Error("REQUEST_TIMEOUT_MS must be a positive integer");
      }

      return parsed;
    }),
});

export const config = envSchema.parse(process.env);

这里不是简单地到处 .trim().default(),而是按配置类型分开处理。

DATABASE_URL 可以 trim,因为它通常不应该包含前后空格。

JWT_SECRET 不直接 trim,而是校验是否存在意外空白。因为 secret 是身份和签名边界,系统不应该擅自修改它。

AI 的问题不是加了 trim,而是不知道哪些地方不能 trim

环境变量场景正好能说明 AI 防御性代码的核心问题。

AI 加 trim() 的动机是合理的:环境变量是外部输入,确实可能有格式问题。

但它经常不区分:

  • 配置值和密钥
  • 可选配置和必填配置
  • 开发默认值和生产默认值
  • 空字符串和未配置
  • 非法值和缺省值
  • 可恢复错误和启动失败错误

这就导致它写出一种很圆滑但危险的配置读取代码:

const apiKey = process.env.API_KEY?.trim() || "";
const databaseUrl = process.env.DATABASE_URL?.trim() || "localhost";
const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";

这不是生产级健壮性,而是在用默认值掩盖部署错误。

更好的工程原则是:

环境变量读取可以防御,但不能静默兜底。

普通字符串:可以 trim,但要校验空值。
数字配置:先判断缺失,再解析,再校验范围。
枚举配置:trim 后必须命中允许列表。
URL 配置:trim 后用 URL 解析校验。
secret 配置:不要偷偷 trim,发现意外空白就启动失败。
生产必填配置:不要默认值,缺失就 fail fast。
低风险配置:可以有明确默认值。

让 AI 少写错误防御代码,可以直接这样约束

以后让 AI 写配置代码时,不要只说帮我写得健壮一点。这句话很容易让它到处加兜底。

可以直接这样要求:

请写一个 TypeScript 配置加载模块,要求:

- 所有环境变量只允许在 config 模块中读取
- 应用启动时完成解析和校验
- 必填配置缺失时直接抛错,禁止静默 fallback
- PORT、REQUEST_TIMEOUT_MS 这类低风险配置可以有默认值
- DATABASE_URL、JWT_SECRET、API_KEY、SESSION_SECRET 禁止默认值
- 普通 URL 和枚举值可以 trim
- secret 不要自动 trim,如果出现前后空白应直接报错
- 不要使用 process.env.X || default 这种写法
- 数字配置必须显式 parse,并校验整数、正数和范围
- 输出一个类型明确的 config 对象,业务代码只能使用 config,不直接读 process.env

这样生成的代码会稳定很多,因为你把防御的位置和不能兜底的位置都说清楚了。

总结

AI 喜欢写防御性代码,是因为它面对的是不完整上下文。它不知道哪些错误应该抛出,哪些错误可以恢复,哪些值已经在上游校验过,于是倾向于用空值判断、默认值、trim()try/catch 来让局部代码看起来更稳。

读取环境变量时,这种倾向会更明显。环境变量确实属于边界输入,需要解析、校验和类型转换。Node.js 中环境变量最终都是字符串,配置又会随着部署环境变化,所以 AI 自动加 trim() 和默认值并不奇怪。

真正的问题是,环境变量不能被粗暴兜底。PORT 可以默认,JWT_SECRET 不能默认;普通 URL 可以 trim,secret 不应该偷偷 trim;非法配置应该启动失败,而不是运行时返回空字符串、null 或弱默认值。

好的防御性代码不是到处兜底,而是:

  • 在边界处严格校验
  • 在核心逻辑里保持契约清晰
  • 对可恢复失败结构化表达
  • 对不可恢复错误 fail fast
  • 对生产必填配置拒绝默认值
  • 对 secret 保持原样,并校验异常格式

AI 生成代码最需要审查的地方,往往不是它有没有考虑异常,而是它有没有把真正应该暴露的问题悄悄吞掉。

给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具

2026年5月9日 18:30

你的 AI 每次对话都在重新推导知识。而一个由 Agent 自己维护、会复利增长的 Wiki,让它越用越聪明。

这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说 /memex:capture/memex:ingest/memex:query,Agent 自己知道怎么做。


一、Karpathy 在 2026 年 4 月提出了一个思想

HE9kEdZaMAADLIU.jpg

Andrej Karpathy 是 OpenAI 创始团队成员、前 Tesla AI 总监。2026 年 4 月 4 日,他在 GitHub Gist 上发布了一篇 LLM Wiki Pattern,系统阐述了一个思想:

为什么人类用 Wiki 积累知识,而 AI 每次对话都在从零推导?

他的主张很直接:给 LLM 一个结构化 Markdown Wiki,让它自己维护。人类只负责往 raw/ 里扔源材料,LLM 负责把知识编译进 wiki/——更新概念页、建立交叉引用、标注矛盾、写综合页。每轮对话不是"检索",是"阅读一本已经写好的书"。

他打了个比方,传得很广:

"Obsidian is the IDE, the LLM is the programmer, the wiki is the codebase."

翻译过来就是:"Obsidian 是 IDE,LLM 是程序员,Wiki 是代码库。"

什么意思?你写代码时——IDE 是你的界面,程序员是写代码的人,代码库是持续构建的产物。类比到这里——Obsidian(或任意 Markdown 浏览器)只是你看知识的界面,LLM 才是真正写知识的人,Wiki 就是 LLM 持续构建和维护的知识产物。你不写 Wiki,你看 Wiki;LLM 不读 Wiki,LLM 写 Wiki。

Karpathy 的核心洞见其实用一句话就能说清——他把知识库当代码仓库管理:

软件工程 知识库工程
src/ raw/(原始资料,不可变)
build/ wiki/(编译产物,LLM 自动生成)
编译器 LLM(把 raw 编译成结构化 wiki)
IDE Obsidian / 任意 Markdown 浏览器
Lint / CI 健康检查(断链、矛盾、过期页)
增量编译 每次只 ingest 新增的 raw,不改旧文件

我是开发出身,第一眼看到这张表就懂了。这不就是 CI/CD 的知识库版本吗?

软件工程 → 知识库工程 映射

而 Karpathy 用了一个词来概括这一切——编译(Compile)。把原始资料编译成结构化知识。raw 是源码,wiki 是编译产物。你不会把 .class.java 混在一起,笔记也一样。

核心区别在于:RAG 每次重推,Wiki 持续复利。

这句话拆开看——

RAG LLM Wiki
知识形态 文档切片,无关联 结构化页面,交叉引用
更新方式 重新索引 Agent 直接编辑 Markdown
查询 向量相似度拼凑 读已组织好的页面
累积性 没有复利 每次 ingest 在旧知识上修改、关联
所有权 在厂商的向量库里 在本地 Git 仓库里

Karpathy 给的是思想。我把它做成了工程:memex


二、memex 怎么用?在 agent 对话里说话就行

最重要的概念先摆出来——

你不是在终端敲 memex distillmemex ingest。你是在 agent 对话框里说 /memex:capture/memex:ingest/memex:query。CLI 只在 Agent 脚下跑,你感觉不到它。

memex 提供了 6 个 slash command,覆盖完整的知识生命周期:

Slash Command 你做什么 Agent 做什么
/memex:capture 给 Agent 一个 URL、一段文字、一个文件 Agent 保存到 raw/,记录出处,不变形
/memex:ingest "把这些新东西消化进知识库" Agent 读 raw 源材料,更新 concept/entity/source 页面,写交叉引用,更新 index
/memex:query "关于 X,我们知道哪些?" Agent 搜 wiki,综合答案,带引用
/memex:distill "这次对话有不少好结论,存下来" Agent 把会话要点蒸馏成结构化 raw 笔记
/memex:lint "检查一下知识库健不健康" Agent 跑机械检查 + 语义扫描,报问题,修问题
/memex:status "看看知识库现在什么状态" Agent 报告页面数、最近变化、待处理项

你不需要记住命令参数。你只需要用自然语言告诉 Agent 你想干什么,Agent 自己调对应的 slash command。

别上来就搞 RAG

一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。

Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。

这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。

每次问答也能存回知识库

还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。

你每跟 AI 聊一次,知识库就增加一层。这就是复利。

知识编译管线:capture → ingest → query → lint


三、五个场景:memex 到底能带来什么价值

下面这五个场景,是我自己用了三个月的真实感受。

场景 1:长期研究 —— 让知识库自己长起来

痛点:你在研究"Agent Memory vs RAG"这个话题,今天看一篇论文,明天读一个开源项目,后天和 AI 讨论两个小时。三周后你想写篇总结文章——发现所有讨论散落在十几个聊天窗口里,找不到线索。

怎么做

你:/memex:capture https://arxiv.org/abs/xxxx --scene research
你:读到新的论文或讨论出新想法时,继续 capture 进去
你:积累几份材料后——
你:/memex:ingest 把这些新研究材料消化进 wiki
你:/memex:query "agent memory 和 RAG 的设计取舍,我们目前知道哪些?"

你始终在 agent 对话里。Agent 负责:

  • 把每篇论文、每次讨论存成 raw/research/ 下的源文件
  • ingest 时把新知识合并进 concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
  • query 时综合 wiki 里的所有内容,带引用回答

价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"

场景1:长期研究 — 知识库随时间生长

场景 2:长期项目 —— 让项目记忆可继承

痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。

怎么做

你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene

你:读当前代码和文档,然后起草这个项目的 architecturecommand-designknown-pitfalls 页面
Agent:读源码,写带有文件路径引用的 code-reading 笔记到 raw/

你:/memex:ingest 把这次 code-reading 结论写进项目 wiki
Agent:更新架构决策页、命令设计页、已知坑页、测试契约页

每次新会话开始:

你:/memex:query "继续 ai-memex-cli 网站和文档工作"
Agent:从 wiki 拉出最近的 handoff 笔记、未完成的任务、需要遵守的测试契约
你:从上次中断的地方继续

价值:项目知识不再是散落在聊天里的只言片语。新 Agent 开局就能回答"为什么这么设计"、"哪些地方容易踩坑"、"上次改到哪了"。代码仓库本身就是 source of truth,wiki 存的是 Agent 从代码、文档、issue、反馈中提炼出来的可继承理解

场景2:长期项目 — 三个 Agent 共用一个 wiki

场景 3:跨会话继承 —— 多次会话之间携带上下文

痛点:今天 Claude Code 做了一半,明天 Codex 继续,后天出差回来用 Cursor 检查。每个新会话都是一个黑洞——上下文全丢。

怎么做

你:/memex:distill 这次 Codex 会话,写清楚做到了哪、下一步做什么、有没有阻塞
Agent:找到当前 agent 的会话数据,蒸馏成 raw/sessions/ 下的结构化笔记

你:/memex:ingest 把这次 handoff 合并进项目记忆
Agent:更新项目 wiki 中的进度页和 log.md

——第二天,换了一个 agent——

你:/memex:query "上次中断的工作,下一步是什么"
Agent:从 wiki 里拉出 handoff 笔记和未完成项

跨 Agent 完全无感——Claude Code 写的,Codex 能读;Codex 补充的,Cursor 继续改。它们不共享一个聊天窗口,它们共享 raw/wiki/index.mdlog.md

价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。

场景3:跨会话继承 — 有无 memex 的对比

场景 4:对话沉淀 —— 把聊天里的好结论留下

痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。

怎么做

你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题

你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容

什么样的结论值得沉淀?

  • 产品定位:怎么描述产品、避免用什么说法
  • 架构边界:为什么 CLI 不做语义层、为什么 raw 不可变
  • Bug 根因:排查路径、实际原因、回归测试要点
  • 被否决的方案:为什么没选、当时的前提是什么

价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。

场景4:对话沉淀 — 从聊天到 wiki 的蒸馏流

场景 5:结构化维护 —— 让 Agent 持续维护知识,而不是只回答一次

痛点:大部分人用 AI 的模式是"问一次答一次"。知识在回答完后原地消失。没人去更新、去合并重复页、去修断链、去标记过期内容。

怎么做

你:/memex:status
Agent:报告 vault 整体健康状况——页面数、最近更新的 source、哪些页面过时了、哪些维护任务待处理

你:/memex:lint 检查断链、孤儿页、过期页、缺失的 frontmatter
Agent:跑机械 lint(路径、链接、frontmatter 正确性)+ 语义扫描(矛盾、重复、过时论断)
你:机械问题直接修,语义问题先给我看方案
你:把 Karpathy 的 LLM Wiki gist 加入知识库
Agent:capture 源文件 → 创建 concept 页 → 更新相关页面交叉引用 → 写 log
你:告诉我改了什么,还有什么需要 review

价值:Wiki 不是一堆文件的堆积。它是一个被持续维护的结构化系统。每次 Agent 用它,也能同时改善它。重复页被合并或标注、孤儿页被找到、断链被修复、index.md 是真正的导航入口而非文件列表。

场景5:结构化维护 — lint 健康检查的四个维度


四、Agent 和 CLI 的分工边界

这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。

谁负责 做什么
Agent Claude Code / Codex / Cursor 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写
Slash Command /memex:capture 等 6 个 把用户的自然语言意图翻译成底层 CLI 调用
CLI memex 命令行工具 文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API

这意味着:

  • 你的知识不绑定任何厂商——Agent 可以换,wiki 不变
  • 你的知识是 Git 化的 Markdown——可以 diff、可以 blame、可以回退
  • CLI 永远不帮你做语义决策——"这两个页面是不是该合并"这种问题,Agent 自己判断但会问你

memex 三层架构:raw(不可变)→ wiki(编译)→ 输出

CLI 的补充能力

上面 6 个 slash command 覆盖日常 90% 的交互。CLI 底层还提供几个高阶能力,但不建议作为日常入口:

CLI 命令 用途 说明
memex watch 自愈守护进程 监听 raw/ 变化,自动触发 ingest → lint 循环。适合长期跑
memex inject 上下文注入 会话开始前,按任务描述从 wiki 拉最相关页面注入当前上下文
memex install-hooks 安装 Agent hooks 把 SessionStart / SessionEnd hook 写入 Agent 配置,自动 distill 和 inject
memex search 命令行搜索 全文搜索 wiki,适合脚本化场景

但这些不是入口。日常入口是 agent 对话框,是说 /memex:query 而不是敲 memex search


五、两周跑通最小闭环

如果你想试,不需要什么额外工具。装好 memex,在你的 Agent 里说话就行。

第一周:搭 raw → wiki 的最小循环。 装好 memex,运行 memex onboard。然后开始往知识库喂东西——看到好文章、好推文、好想法,直接对 Agent 说 /memex:capture。攒够 5 到 10 条后,说 /memex:ingest 把这些新素材消化进知识库。Agent 会生成摘要、提取概念、更新索引。

第二周:让问答开始积累,跑第一次健康检查。 每次对知识库做复杂提问,结果让 Agent 存回 wiki。然后说 /memex:lint 给知识库做一次全面体检。Agent 会扫出断链、矛盾、过期页、孤儿页——先让它修机械问题,语义问题你看一下再决定。

两周之后你有一个能持续运转的小系统。规模不重要,流程跑通了就行。后面就是往 raw/ 里不断喂素材,让 Agent 持续编译。


六、知识库的"GitHub 时刻"

回到 Karpathy。他那篇 Gist 的最后一句话是:

这套东西目前仍然像一堆 hacky scripts,但有空间做成 incredible new product。

我想到 2006 年前的版本控制。那时候也是 svn、cvs、git 命令行,只有程序员在用。然后有人把它做成了 GitHub,整个协作方式都变了。

个人知识库可能正在类似的节点。今天它是 Obsidian + LLM + 手搓脚本的组合,看起来还很粗糙。但底层范式已经有了:把知识当代码管理。 有输入,有编译,有产物,有测试。

如果你是程序员,好消息是你不需要学任何新东西。代码仓库怎么管,知识库就怎么管。你积累了这么多年的工程直觉,终于可以用在自己的笔记上了。

Karpathy 原文里还有一段话:

人类放弃 Wiki 是因为维护负担的增长速度永远超过它带来的价值。你得亲手写每个页面、手动保持一致性、记住所有交叉引用。

但 LLM 不会无聊。它可以一次触碰 10-15 个页面,把新知识合并进去,更新索引,同时保持系统自洽。

人的工作:策展、取舍、提问、思考。LLM 的工作:剩下的全部。

memex 做的,就是把这句话变成可以跑的东西。

别让你的笔记腐烂。让它们被编译。


快速开始:

npm install -g ai-memex-cli
memex onboard

然后在你的 Claude Code / Codex / Cursor 里说第一句话:

你:/memex:capture https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f --scene research
你:/memex:ingest Karpathy 的 LLM Wiki 思想,作为 research 场景的第一份材料
你:/memex:query "Karpathy 的 LLM Wiki 核心思想是什么?"

给 AI 一份会生长的记忆。


项目地址: github.com/zelixag/ai-…

理念来源: Karpathy's LLM Wiki Pattern

TRAE SOLO 移动端正式上线:手机也是随身工位,随时随地进入「Vibe Working」

作者 站着
2026年5月8日 09:34

不再被电脑束缚,一个 Agent 在三端自由流转——你的灵感和任务都会自然接力

01 一个熟悉的场景

周五下班前,你在公司电脑上跑了一个需要几小时才能完成的训练脚本。人离开办公室,电脑留在工位。路上手机突然弹出警报:程序崩了。或者一切正常,但你无法确认进度,也无法干预……

这就是当下很多 AI 开发者的真实焦虑:明明 Agent 已经能帮我们做很多事,我们却仍然离不开那台「住着 AI」的电脑。

OpenClaw 的出现曾让很多人看到希望——通过 IM 软件远程遥控电脑上的 Claude Code。但实际用起来,依然无法实时查看进程、对 Agent 的下一步操作没有预期,甚至想打断都找不到 Ctrl+C。久而久之,人走到哪,电脑还是得背到哪。

如果,手机、电脑、云端的工作流和 Agent 能彻底打通呢?

几天前,TRAE SOLO 移动端正式上线,实现了 iOS / Android + Mac / Windows + 网页 三端全量打通。无需邀请码,下载即用。

02 手机 ≠ 缩水版桌面端

TRAE SOLO 最核心的设计理念是:换设备,不断流

手机端、桌面端、网页端共享:

  • 同一个 Agent

  • 同一套文件系统

  • 同一段对话上下文

你在地铁上用语音说了一个产品 idea,手机端 Agent 可以立刻把它整理成 PRD 草稿;到公司打开电脑,刚才的文档已经躺在工作区,继续精细化编辑。

开发者也一样:路上想到一个 bug 的解法,直接对手机说,Agent 会将其拆解成修改计划并同步到 PC 端。你坐下后,代码已经等在那里。

TRAE SOLO 做的是:把「想到」到「做到」之间的搬运成本压缩到最短。

03 实战:用手机做一个小项目

一位日本开发者做了一个浏览器插件:定时在屏幕上显示一只大肥猫,提醒你休息。

我看到后很想自己也做一个。但没有人愿意在手机上敲大段格式化提示词。我要让 Agent 解决所有问题。

TRAE SOLO 移动端包含完整的 MTC(Model-Think-Code)Code 功能。从产品规划、提示词设计到代码落地,全在手机上完成。

我先是让 MTC 理解这个项目并输出完善的提示词(Markdown 文件),保存到手机后,切换到 Code 模式直接开始构建。整个过程,手机揣兜里,等通知就行。

最后到工位时,猫猫插件已经在 Chrome 里跑起来了。

一个完整的产品概念,从灵感闪现到可运行的产物,全程只靠一部手机。

04 三个值得关注的新能力

实时语音交互讨论

你可以直接用手机和 Agent 讨论一个产品想法、运营方案或代码问题。过去手机只是「记下来,回去再说」;现在可以直接开展讨论,Agent 会生成会议纪要、沉淀思路,并进一步下发任务。

实测体验:Agent 反应快、建议质量高,甚至会在每次发言后主动引导你深化思考。持续 5 分钟的讨论结束后,它自动输出了完整的讨论总结——就像专业会议软件的会后纪要。

唯一遗憾:语音讨论时暂时无法联网搜索或执行实际任务。如果未来补齐这一点,TRAE SOLO 在语音交互赛道将几乎没有对手。

飞书 CLI 接入

对产品、运营、市场、管理者非常实用。把飞书文档链接交给 SOLO,它可以:

  • 理解文档内容

  • 基于上下文生成方案、报告或任务拆解

  • 将修改后的文档以卡片形式沉淀,方便后续查看和编辑

实际测试中,我们把 SOLO 接入了自己的飞书工作流。开放权限后,它像一位新入职的编辑,快速整理出五一期间遗漏的重要 AI 资讯,并按日期、类别、重要性标记清楚。甚至能从混乱的选题文档中为每个事件提取出相当准确的标题。

定时任务

提前设定 Prompt 和触发频率(如每天上午),SOLO 会自动执行并产出结果。例如:

  • 每天自动整理 Cursor、Claude Code 等产品的最新动态

  • 生成日报发送给自己

这让 Agent 真正成为一个长期主动在线的助手,持续完成信息收集、整理和汇报。

05 从「闪念胶囊」到「闪念完成体」

很多好想法不是坐在电脑前想出来的。走路、洗澡、坐车、睡前,灵感突然「啪」地冒出来。罗永浩的「闪念胶囊」是一个伟大的尝试——让灵感在第一秒被稳稳接住。

但问题在于:它只做到了断点保存,却跟不住工作流。

TRAE SOLO 就是那个「闪念胶囊完全体」。

过去,胶囊负责保存念头;现在,Agent 接过下一棒——把念头补全、整理、拆解,并直接推向真正的工作流。

06 智能体的主场到底在哪里?

过去两年,AI 编码领域的竞争主线很清晰:谁能更懂代码、更快补全、更准修 bug,谁就能抢下开发者的桌面。Cursor、GitHub Copilot、TRAE SOLO 桌面版都是这一阶段的产物。

但它们本质上仍是桌面软件时代的延伸——提升了效率,却没有改变「人的心流」。

历史是个圈:

  • 互联网 1.0:信息上网,人坐在电脑前浏览

  • 互联网 2.0:人上网,开始生产内容和互动

  • 移动互联网:服务跟着人走,场景无处不在

今天,TRAE SOLO Mobile 正在推动智能体进入下一个阶段:工作流跟着人走,在不同设备间自然流转,在不同场景里接力完成任务。

同样的,移动端大大降低了智能体的使用门槛。当 AI 编程工具只存在于 IDE 里,它天然是开发者的专属工具。但当它可以通过手机 + 语音 + 文档 + 业务流程操作时,产品经理、运营、管理者、测试、设计,都能把它纳入自己的工作流。

所以,智能体的主场到底在哪里?

答案或许不再是 IDE,不再是浏览器,不再是任何一个载体。

真正的主场,是人的工作流本身。

TRAE SOLO 移动端已全量开放:iOS、Android、Mac、Windows 均可下载,立即体验「随时随地的 Vibe Working」。

我用两周半 Vibe Coding 做了一个前额叶训练的微信小程序

2026年5月8日 17:46

体验方式在最后~

最近花了两周半的时间vibe coding了一款微信小程序,这也是属于自己的第一款产品,叫「前额叶专注训练」,定位是前额叶训练小游戏。简单说,就是把舒尔特方格、数字记忆、N-Back、Stroop、go/no-go、河内塔、24 点这类认知训练任务,包装成一个轻娱乐产品:用户每天玩几局,训练自己的专注能力和记忆力。

这个项目从需求讨论、技术方案、功能实现,都是我跟 AI 一轮一轮聊出来的,自己没有写过一行代码,整个过程大概两周半,从五月初开始规划,到五月前开发完成并发布上线,备案的过程也是开始开发的时候就完成了。写这篇文章是想分享下vibe coding的一些感受。

image.png

第一,让AI写代码之前,一定要把需求聊清楚

从一开始的windsurf、cursor、trae到claude code和codex,我都使用过,在这里不讨论谁好用谁不好用,不管使用哪个代码编辑器,不管写多大的功能还少就是一个小的迭代,我的习惯都是先跟AI把需求聊明白,我习惯在每次对话后加上一句:先产出实现方案,等我同意再开始写代码。这样做的好处就是不用每次AI生成的代码不满意再回退,经常用AI编程的应该都深有感受,AI生成的代码一次次的回退是很招人烦的。所以,千万不要一上来就跟 AI 说“帮我写一个xxx小程序”,“帮我实现一个xxx功能”。这样做运气好的话能得到理想的效果,但是不保证每次运气都那么好。

我一开始也只是一个很模糊的想法:想做一个训练前额叶能力的小程序,而且我只知道一个舒尔特方格(我承认这个是从别人那得到的灵感),后来跟 AI 聊了之后,发现有好多可以实现的小游戏都可以用来训练专注力和注意力,这就是和AI先聊的好处。这个过程中,AI 的作用不是替我拍板,而是不断帮我把想法摊开。比如我说想做“脑力训练”,它会继续追问或展开成工作记忆、持续注意、抑制控制、认知灵活性、计划决策这些方向。然后我们再反过来判断,这些方向能不能变成普通用户愿意玩的小游戏。

确定了大概要做的内容,之后就是产出产品文档,有了产品文档之后,再让AI帮忙产出技术文档,有了这两份文档,基本是就知道该怎么实现了。另外还有一点,一定要分阶段实现,每一阶段开发完成后一定要验证,有bug就让AI改,避免最后整体验证bug数量过多的问题。

所以 vibe coding 第一步不是写代码,而是把需求聊到足够具体。你越能说清楚边界,AI 写出来的东西越接近你想要的效果。反过来,如果自己都没想清楚,AI 只会很努力地把混乱放大。

先实现一个能跑通的MVP版本

需求聊完之后,我没有一开始就让它帮我做完整的项目。而是先是做一个能跑通的版本,这个版本一定能跑通你的核心流程,比如我的小程序的核心流程就是用户能打开一款游戏去玩,所以我的第一版的功能就是:用户能打开小程序,能登录,能看到第一款游戏,能开始一局游戏即可。

这个链路跑通以后,说明第一版本的代码没问题,此时再往上叠加其他功能才最合适。否则你可能做了很多页面,但核心闭环其实是断的。

这样后面加游戏就和第一个游戏的开发一样,变成了一个固定流程:

  1. 写游戏组件;
  2. 写游戏定义;
  3. 注册到游戏列表;
  4. 在云函数里补评分规则。

所以项目后来能比较快地扩到 12 款游戏,不是因为每款游戏都随便生成一下就完了,而是因为前面先把模式定住了。AI 很适合在这种稳定模式下继续扩展。如果每个页面都从零开始聊,速度反而会越来越慢。

这也是我对 MVP 的理解:不是做一个很丑的半成品,而是先把最小闭环做扎实。它可以功能少,但关键链路一定要能跑。

即使产品失败了,没人用也不要气馁

忘了之前在哪看的,说独立开发人员在开发一款应用前都会有一种错觉,觉得自己的应用一定会火,一定有人用,我就是这样。所以在上线后天天盯着数据,发现用的人少心态都不好了,但其实成功毕竟是少数的,尤其是现在有了AI的加持,上线一款应用的成本这么低,注定会有许多人的产品一定没人用。但是并不是说没人用就一点收获都没有,以前总是想着自己作出一款属于自己的产品,这不就有了,现在试错成本这么低,多做几款又何妨。所以一定要放平心态。

最后的感受

两周半做出一个备案上线的小程序,在没有AI之前是不可能完成的事,但有了AI之后就将这种不可能变成了可能。AI 最大的价值,是让我一直保持“下一步能做什么”的状态。它能把模糊想法变成初稿,把初稿变成代码,把报错变成修改建议,把新功能拆成文件和步骤。

所以,不必焦虑AI把我们替代了这类问题,而是要积极的拥抱AI,用AI将自己脑子中的想法落地,这才是正解。

打个广告,欢迎体验,有问题欢迎私聊~

打开微信搜一搜:前额叶专注训练 image.png 也欢迎扫码体验

image.png

成为AI全栈 - 第4课:Drizzle ORM SQLite Elysia 数据库实战

作者 铁皮饭盒
2026年5月8日 16:41

从今天开始, 你是架构师,学会关键词就行,代码让AI实现😁


数据库就像持久化的 Excel,ORM 让你用代码操作它


今天你会学到这些关键词

| 关键词 | 一句话解释 | | :-- | :-- | | Drizzle ORM | TypeScript 友好的 ORM,用代码操作数据库 | | SQLite | 轻量级文件数据库,无需安装 | | Elysia | 高性能 Web 框架,链式 API 设计 | | 参数化查询 | 防止 SQL 注入的安全查询方式 |

一句话总结:用 Drizzle ORM + 参数化查询操作 SQLite,让 Elysia 服务拥有真正的数据持久化能力。


上节课回顾

上节课我们用 Elysia 实现了用户管理 API:

GET /users      → 查询所有用户
GET /users/:id  → 查询单个用户
POST /users     → 创建用户
PUT /users/:id  → 更新用户
DELETE /users/:id → 删除用户

但数据存在内存里:

const users = new Map();

问题:

  • • 服务重启,数据丢失

  • • 无法多服务共享数据

  • • 不能复杂查询

解决方案:使用数据库。


数据库是什么?

一句话:持久化存储数据的地方。

内存存储(Map/数组)    数据库(SQLite/MySQL)
─────────────────────────────────────────────
• 服务重启数据丢失      • 数据持久保存
• 存在内存里            • 存在硬盘上
• 简单快速              • 功能强大,支持复杂查询

类比:

  • • 内存存储 = 草稿纸,写完就扔

  • • 数据库 = 笔记本,永久保存


为什么选择 SQLite 学习数据库?

SQLite 的适用场景:

| 场景 | 是否适合 SQLite | | :-- | :-- | | 学习数据库基础 | ✅ 完美 | | 开发测试环境 | ✅ 快速搭建 | | 小型应用(用户 < 10万) | ✅ 推荐 | | 独立桌面/移动应用 | ✅ 推荐 | | 高并发写入(> 1000 QPS) | ❌ 不推荐 | | 多进程同时写入 | ❌ 不推荐 | | 需要网络访问 | ❌ 不推荐 |

为什么用 SQLite 学习 SQL?

传统数据库(MySQL/PostgreSQL)    SQLite
─────────────────────────────────────────────
• 需要安装数据库服务            • 零配置,零安装
• 需要启动数据库服务            • 直接打开文件就能用
• 配置复杂(用户、权限、端口)    • 就是一个 .db 文件
• 占用系统资源多                • 占用资源极少
• 命令行工具需要单独学习         • 直接用 AI 生成 SQL

SQLite 学习路线:

第4课(SQLite)→ 理解数据库和 ORM 基础
     ↓
第8课(PostgreSQL)→ Docker 部署生产级数据库
     ↓
第15课(MySQL)→ Java Spring Boot 生产环境

一句话:先用 SQLite 快速入门,等项目大了再换 PostgreSQL。


SQL 是什么?

SQL 是操作数据库的语言。

-- 查询所有用户
SELECT * FROM users;

-- 创建新用户
INSERT INTO users (name, email) VALUES ('张三''zs@example.com');

-- 更新用户
UPDATE users SET name = '李四' WHERE id = 1;

-- 删除用户
DELETE FROM users WHERE id = 1;

但我们不需要手写 SQL。


ORM 是什么?

ORM = 用代码操作数据库,不用写 SQL。

// 不用写 SQL,用代码操作
await db.insert(users).values({ name: "张三", email: "zs@example.com" });

// 自动转成:INSERT INTO users (name, email) VALUES (?, ?)

好处:

  • • ✅ 不用记 SQL 语法

  • • ✅ 类型安全,IDE 有提示

  • • ✅ 代码更易维护


Drizzle ORM 简介

Drizzle 是一个轻量级、类型安全的 ORM,完美支持 Bun.js 和 Node.js。

安装:

bun add drizzle-orm
bun add -d drizzle-kit

# Node.js 用户
npm install drizzle-orm
npm install -D drizzle-kit

用 AI 生成完整代码

复制这段提示词:

用 Bun.js + Drizzle ORM + SQLite 创建用户管理系统

要求:
1. 数据库配置
   - 使用 bun:sqlite(Bun 用户)或 better-sqlite3(Node.js 用户)
   - 数据库文件 app.db

2. 用户表结构:
   - id: 整数,自增主键
   - name: 文本,必填
   - email: 文本,必填,唯一
   - createdAt: 时间戳,默认当前时间

3. 实现 RESTful API:
   - GET /users - 查询所有用户
   - GET /users/:id - 查询单个用户
   - POST /users - 创建用户
   - PUT /users/:id - 更新用户
   - DELETE /users/:id - 删除用户

4. 安全要求:
   - 所有 SQL 使用 :name 占位符
   - 禁止字符串拼接 SQL

5. 统一响应格式:
   { success, data, message }

6. 使用 Elysia 框架

请生成完整的项目代码,包含:
- schema.ts - 表定义
- db.ts - 数据库连接
- index.ts - API 路由

AI 生成的代码结构

1. schema.ts - 定义表结构

import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  idinteger("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date())
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

对应 SQL:

CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  created_at INTEGER NOT NULL
);

2. db.ts - 数据库连接

💡 Bun 用户:使用 bun:sqlite💡 Node.js 用户:使用 better-sqlite3

// Bun 用户
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.run(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);
// Node.js 用户
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);

3. index.ts - API 实现

import { eq } from "drizzle-orm";
import { Elysia } from "elysia";
import { db } from "./db";
import { users } from "./schema";

const app = new Elysia()
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })

  // 查询所有用户
  .get("/users", async () => {
    const allUsers = await db.select().from(users).all();
    return {
      success: true,
      data: allUsers,
      message: "查询成功"
    };
  })

  // 查询单个用户
  .get("/users/:id", async ({ params: { id }, set }) => {
    const user = await db.select().from(users).where(eq(users.id, Number(id))).get();

    if (!user) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: user,
      message: "查询成功"
    };
  })

  // 创建用户
  .post("/users", async ({ body, set }) => {
    const { name, email } = body as { name: string; email: string };

    if (!name || !email) {
      set.status = 400;
      return { success: false, data: null, message: "name 和 email 不能为空" };
    }

    try {
      const result = await db.insert(users).values({
        name,
        email
      }).returning();

      set.status = 201;
      return {
        success: true,
        data: result[0],
        message: "创建成功"
      };
    } catch (error) {
      set.status = 400;
      return { success: false, data: null, message: "邮箱已存在" };
    }
  })

  // 更新用户
  .put("/users/:id", async ({ params: { id }, body, set }) => {
    const { name, email } = body as { name?: string; email?: string };

    const result = await db.update(users)
      .set({ name, email })
      .where(eq(users.id, Number(id)))
      .returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: result[0],
      message: "更新成功"
    };
  })

  // 删除用户
  .delete("/users/:id", async ({ params: { id }, set }) => {
    const result = await db.delete(users).where(eq(users.id, Number(id))).returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: null,
      message: "删除成功"
    };
  })

  .listen(3000);

console.log(`Server running at http://localhost:${app.server?.port}`);

💡 Node.js 用户注意:需要使用 Node 适配器

import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

// ... 路由代码 ...

.listen(3000, node);  // ← 加上 node 适配器

运行测试

安装依赖:

bun install
# Node.js 用户
npm install

启动服务:

bun run index.ts
# Node.js 用户
npm run dev

测试接口:

# 创建用户
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "张三", "email": "zs@example.com"}'

# 查询所有用户
curl http://localhost:3000/users

# 服务重启后,数据还在!

核心概念对照

| 概念 | Drizzle 代码 | 对应 SQL | | :-- | :-- | :-- | | 查询所有 | db.select().from(users) | SELECT * FROM users | | 条件查询 | .where(eq(users.id, id)) | WHERE id = ? | | 插入 | db.insert(users).values({...}) | INSERT INTO users ... | | 更新 | db.update(users).set({...}) | UPDATE users SET ... | | 删除 | db.delete(users) | DELETE FROM users |


安全提示:参数化查询

❌ 错误写法(SQL 注入风险):

const sql = `SELECT * FROM users WHERE email = '${email}'`;
// 如果 email = "' OR '1'='1"
// 变成:SELECT * FROM users WHERE email = '' OR '1'='1'
// 结果:返回所有用户!

✅ 正确写法(Drizzle 自动参数化):

await db.select().from(users).where(eq(users.email, email));
// 自动使用占位符,安全!

核心收获

今天学习了:

✅ 数据库 = 持久化存储✅ ORM = 用代码操作数据库✅ Drizzle = Bun.js/Node.js 的 ORM 选择✅ 参数化查询 = 防止 SQL 注入


下节课预告

第5课:登录功能怎么实现?一文搞懂认证

我们将:

  • • 理解认证的概念

  • • 学习 JWT 工作原理

  • • 实现注册登录功能


思考题:

如果要给文章表添加一个外键关联用户(作者),表结构应该怎么设计?

欢迎在评论区分享你的设计。


如果觉得有帮助,欢迎点赞、在看、转发。

2026年,为什么NestJS + Monorepo越来越流行了 ❓❓❓

作者 Moment
2026年5月7日 09:58

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

这两年和别人聊下来,有个挺朴素的观察:工具都差不多,Cursor、Claude Code、Copilot 换来换去,有人照样顺滑往前推,有人却被 AI 拖进更深的坑里。倒不一定是模型突然变差了,更像是仓库本身经不起这么快地改——你一提速,漏的地方也跟得上提速。

我这边遇到过无数次那种很无聊的返工。后端字段改了,前端忘了跟。或者看起来类型都对,实际请求体还是对不上。编译绿灯,线上才发现分支走错。一出问题就先怀疑 prompt,改了两轮发现不对劲——常常是仓库里没有一套固定的摆放方式,模型猜这一步猜对了,下一步就和别处打架。

所以到了 2026 年,我反而更多把 NestJS 和 Monorepo 当作默认选项,不是因为它们听起来高级,单纯是省事:目录大致怎么长、模块怎么切、前后端能不能共用同一份类型说明,至少有个大家都认的底子。AI 跟着改文件的时候,不至于今天一套写法、明天换一套,你自己回看也少猜谜。

以前挑框架会问写着爽不爽。现在会先想过两个月再来需求,我还能不能一眼看出该动哪几块。NestJS + Monorepo 谈不上惊艳,只是让我觉得没那么容易失控。

写出来的快,后面收拾慢

现在问 AI 顺手写一段,在圈里早不新鲜了。身边人多少都会用用 CursorCopilot 一类,写 TS、改多文件的仓库,编辑器也更好跟一点。

省时间的是样板、CRUD、第一遍类型、顺带出来的测试草图。多文件改、读完再改、跑完再交 diff,大家也都摸熟了。网上还有一大把规则文件和模版,抄一抄就能开张。

麻烦的是它仍然吃你仓库长什么样。上下文一碎,就只能对着当前文件蒙,旧接口的臭毛病还能被带回来。约定没写进结构里,同一天里 ValidationPipe、手写 if、跳过注入直接 new 能并存。跨包改一半留一半、临上线才逐行对 diff,都常见。有人习惯 AI 打一版自己再改,省下的时间往往又赔在契约和安全上。

把这些和日常开发叠在一起看,AI 写代码早就不算新闻。起接口、跑 CRUD、补两层类型、顺带生成点测试,交给模型去做,往往不慢,第一眼看上去也像那么回事。别扭的是后半程:很多时候它不是写出 0 分,而是那种能跑、像样、却不对劲的 80 分——lint 不吵,预览也能点开,但分层含糊、命名各写各的、同一个概念在不同文件里换了三张脸。你要是真顺着往下叠需求,常常要到第二、第三次改动才猛然醒悟,省下来的时间没花在第一版上,全花在给前面的草率擦屁股上。

后面这几类我最熟:改一个字段,前后端各漏一处;鉴权相关的判断补丁似的散落在好几个文件里;新开的功能完全是另一套文件夹脾气;类型检查安安静静,DTO、落库和前端调用却已经各走各路。偶尔也会嘀咕,这算不算真省力。

我以前也会比谁敲得快、谁能更快翻出文档。现在更在乎仓库省不省返工,少折腾比好看重要。上下文窗口再大,翻起来顺不顺还是看你自己怎么摆文件夹。

好几个仓库并排的时候

很长一段时间里,我都觉得多 repo 很正常:前端一个仓,后端一个仓,再加共享类型包、组件库,听起来分工清清楚楚。

真到了天天开工、AI 也跟着一起改的时候,摩擦就出来了——业务明明是一套东西,代码却被切成几块互不接壤的地盘,没有哪个仓库能单独回答这一整块系统在干什么。人还能靠记忆和聊天记录勉强对齐,模型手里往往只有当前文件附近那点片段,它没有你那套我懂的脑内地图。

后果都很具体:字段名对不齐,import 指到老路径,接口说明还停留在上个版本,这边改了那边没人提醒,前后端各讲各的故事。于是就经常出现那种撕裂:嘴上都说 AI 很强,手头却在骂它不靠谱;细看往往不是模型突然变笨,而是你根本没给它看过全貌,它只能瞎蒙。

Monorepo 对我来说最实在的一条,就是相关代码至少在一个 workspace 里,搜得到、跳转不瞎跳,改一处牵动谁早一点露馅。

单 workspace 那点实在的好处

大家聊 Monorepo,常常一上来就是依赖 hoist、构建缓存、CI 提速、版本对齐——这些都实打实地省钱省时间。若你用的是 Turborepo、Nx 这类任务编排,改 libs/types 再触达 apps/web 时,turbo run build --filter=... 一类命令往往只跑受影响的那几条边,CI 和本地反馈都轻一些;AI 一口气动多个包的时候,也不太容易因为全量 build 太慢把思路打断。但我日常感触更深的,反而是更土的几件事:全局搜索能跨过 apps 和 libs,跳转定义不会再跳到另一个克隆仓库;开一个合并请求可以同时改 apps/api、前端调用处和 libs/types,评审的人也不用先在脑子里拼接三四份改动。

产业报告里偶尔也能看到 Monorepo 与更高采纳率、更少来回改放在一块儿的讨论,口径各自不同,我不打算在这里背具体百分比。我自己觉得更实在的一点是,同一套索引里改契约,少了很多跨仓漏改。

一种常见的摆放方式大概是这样(命名随团队习惯变,道理差不多):

  • apps/web
  • apps/api
  • apps/worker
  • libs/types
  • libs/db
  • libs/auth
  • libs/ui
  • libs/common

我手里在跑的一个仓库用的也是同一套思路,只是 app 名叫 apps/backendapps/frontend,后端在 src 下拆 apischematypes 等,根上还有 Turborepo 缓存和一份给助手看的 AGENTS.md。如下图所示:

Monorepo 目录示意(含 NestJS 后端与 Next 前端)

树一展开,比在文字里凭空想象直观得多。

我以前在多仓库里改过一个 shared type,心里会一直挂着还有没有哪个仓库没 bump;现在在同一个 workspace 里,至少引用关系摊开在同一套工具链底下,TypeScript 或单元测试常常会比人肉更早喊疼——哪里还在用旧字段,哪里页面还在按老形状解构,grep 一下也有谱。

再比如后端改了接口返回字段,前端哪些 hooks、哪些组件真正吃到这一次响应,不必全靠记忆里上次好像聊过。这不是什么玄学体验,就是改动触发的影响范围更容易被看见、被追责到同一次合并请求里。

要做 AI 相关的增量也同理:Embedding、RAG、异步任务到底落在 libs/ai 还是单独 apps/worker,一开始就需要个说得过去的落点,不然半年后全是 import 魔法和临时脚本。Monorepo 不提供正确答案,但它逼你把这一坨归谁管迟早说清楚。

在这套习惯里待久了,工作状态会从我在维护好几个小项目悄悄换成我在推进同一个系统。不是口号,是你真的少了很多切仓库、对版本、猜依赖的上下文切换。

单仓也救不了后端胡写

所有代码塞一个仓库,只解决找得到文件,不解决你在 apps/api 里照样把 controller、service、库表访问、杂七杂八工具揉一团。AI 一次改五个文件,耦合只会涨得更快。

我后来还是上了 Nest,图的是入口、业务、横切几件事在目录上有固定叫法,新人进来知道往哪翻,补丁也能长得差不多。它不算最轻,我就看半年以后加模块还痛不痛。

Nest 那套烦人的分层

第一次学 Nest,很多人都会嫌它重:Module、Controller、Service、Guard、Pipe、Interceptor,条条框框比 Express、Fastify 裸奔多出一截,脚手架一念心里先咯噔一下。

但我后来承认,那些让我觉得烦的概念,多半正是复杂之后会回来的质问——HTTP 入口到底挂在哪儿,业务逻辑能不能别再黏在路由文件里,鉴权和校验是不是每次都重写一遍,异常最后统一长成什么样,跨模块的能力能不能复用而不是复制粘贴。你可以在项目很小的时候装作没看见,等体积上来,它们会以技术债的形式敲门。

Nest 对我有用的地方,就是它催你把那些事摊开:Controller 薄一点,Service 扛事,DTO 把进出的形状说清楚,GuardPipeInterceptor 各管一截横切逻辑。写得丑归丑,至少在一条路上。

后端也不可能接口跑亮就结案,需求和权限还来。框架不写业务,只少几次从口头上重新约分层。

装饰器看多了,反而不容易乱窜

我以前当装饰器和 DI 是口味问题,现在要带着助手一起看代码,utils.ts 堆一切最头疼。Nest 那点样板至少是固定格式:@Controller 像关口,@Injectable() 多半进构造函数,Moduleimportsproviders 能看出依赖往哪边走。错误还会犯,多数是接错一层,不至于每个文件一种新的脾气。

构造函数里写字段比一层层 ../../../../ 好跟,对人类和编辑器都一样。

我不再纠结算不算魔法,只在乎新来的、审稿的、还有自动补全,是不是在同一个习惯里读这套目录。

生成越快,烂摊子越容易铺开

听上去怪,能力强了本应少管。我这边反正是反过来的,一次多出好几个文件,结构松的话脏东西也一起铺开。同样一个模型,在规矩紧的 Nest + Monorepo 里多半是补边角,在老脚本堆里经常是 import 散了、校验抄三遍、servicecontroller 又掰扯不清。

选型我就问两件事,多文件改完会不会散,下个补丁你能不能猜到哪一层动。Nest 不是唯一答案,只是我默认懒得再赌。

至于 Express、Fastify 裸着写,我见过太多靠自觉最后靠不住。轻量栈写小服务爽快,HonoElysia 我都用,业务一长我还是想有一层大家都认的摆放。AdonisFoalTS 也行,模版和社区我这儿常碰到的是 Nest。

前后端接缝那档子事

语法、SQL、状态码啃得动,烦的是两半各搞各的目录、README、环境变量,改需求前先在心里对一遍口头合同,明明一个东西却干出两份工的感觉。

Nest + Monorepo 不能砍掉后端工作量,只是把缝抹窄一点。

同一个 workspace 改 API 和页面,共享类型和同一条 linttsconfig 脚本,少扯等你发包我先对齐版本的皮。以前在多个仓库里的流程,很多变成同一仓库里自己 refactor。

前端写了多年 TS,后端再随便 any 心就裂着。契约放在 libs/types 或用生成出来的 SDK 锁住一层,漂移少一桩是一桩。

包管理、CI、分支照旧两套角色,但至少不用每次从零切换脑回路。熟了以后,很难再忍受接口栏两头吵。

若以 Next.js App Router 或类似前端为主力,只是把 Nest 当成好好写业务和善后数据的那一半,这一套目录语言其实不难对齐。路由负责入口像 pageservice 像抽出去的 server libpipeinterceptor 像中间件层。端到端类型上,有人喜欢 tRPCzod 推断加共享 router,有人喜欢 OpenAPI 生成 client。任选一条你能长期维护的主线,把契约锁在 libs/types 或生成的 SDK 里,AI 在前端敲 mutationfetch 时少一半凭空造字段。本地开发里,turbo(或等价物)跑 dev,改 shared 类型后两端热更新的节奏,也常和 AI 快速试错一小步合上拍。部署侧很多平台能对 monorepoapp 建制品,我不再想维护两份各写各的环境变量叙事。

审稿比生成更费工夫

现在大家爱讲几秒出一个功能。我自己的账本里,真正决定是否划算的,常常是后面的半小时到一个小时:目录有没有乱跑,边界有没有偷偷改写,类型和数据是否仍对齐,联动测试要不要补。如果生成省下打字时间,却成倍加到梳理结构上,账就对不上了。

Nest + Monorepo 做的很大一部分省事,是把一大批低级争议前置掉——共享字段在哪儿声明,模块职责默认怎样划,接口改了哪些地方按理应当红光报错。于是评审补丁时我更常在盯业务:权限有没有漏网的路径,异常场景会不会把脏数据写进去,性能热点是不是被忽视了,需求语义到底有没有偏差。

我现在的习惯能多懒就多懒,先跑测试和类型检查,再读业务。让 AI 顺手起一版 VitestJeste2e 骨架并不贵,红线测试挂了就先迭代 prompt。绿了再谈边界条件。@Injectable() 的好处是 mock provider 也相对直来直去,审 diff 的人会轻松一点。

以前看 AI 的补丁,像是在考古这东西为何出现在此;现在更多像是在核对这块业务说得圆不圆。这不是神话 AI,只是把本该机械的对齐成本压低了一层。

我没打算一锅炖成巨石

Monorepo 听上去像要把所有东西糊在一起,Nest 又像老派人做的三层后端。我自己的用法其实很土,源码和好改的契约放在一起,发布照样可以按 app 拆开。

  • apps/web 托管前端
  • apps/api 托管主 HTTP 服务
  • apps/worker 托管队列或异步消费者
  • libs/types 承载共享契约
  • libs/ai 承载模型调用、RAG、prompt 组装之类
  • libs/authlibs/common 分摊认证与通用工具

仓库可以统一规范,制品依然可以按 app 构建发布;你可以先把复杂度关在清晰的包里,而不是一开始假装自己永远只需要一个 server.ts。这在 2026 格外常见——队列、异步生成任务、检索、后台配置、审计日志、多租户开关,后来都会陆续冒出来。

CI 里只对改动的 appturbo run test --filter=...@...(或等价过滤)之类,也早已是常规操作。共享代码动了,顺带跑会消费它的那几个 app,而不是每次全矩阵。托管侧不少平台认得 monorepo 根目录,apps/web 走静态或边缘,apps/api 单独开服务。源码和契约仍在一处捏着,生命周期和扩容却可以拆开看,不必心理上先投降成巨石。

Nest 自带 microservices、传输层那一套,真要把 auth 或大活拆出去,也还是在同一套路子里长枝,不用再拍脑袋起一套新目录癖。

我更在意的是:这些东西加进来的时候,是顺着现有的 libs/apps 生长,还是被迫堆出一层新的临时目录。前者不一定优雅,但至少有机会保持可读;后者常常意味着下一次 AI 生成又会发明一种新秩序。

人一多,文件夹比嘴上规矩管用

一个人单挑项目的时候,坏习惯还能靠记忆兜底;两三个人一起用 AI,风格漂移的速度会快得离谱。某人习惯函数式拼接,某人偏爱大类;有人把逻辑黏在 controller,有人把所有东西都塞进 util;几周下来,目录看起来像百家饭拼盘。

Nest + Monorepo 对团队的价值,不在于消灭分歧,而在于把大量本该口头重复的规矩,换成打开仓库就能看见的骨架——新功能默认落在哪个 app,共享代码朝哪个 lib 收敛,鉴权和 DTO 的习惯写法是什么。AI 这时更像在同一套轨道上补齐缺口,而不是每人拉着模型朝不同方向发明范式。

新人上手也会轻松一点:不必先听完三场口头约定才能下手改第一段代码,结构本身就带着大部分的别这么写。这当然不完美,但比纯粹依赖自律省心。

仓库根上挂一份短短的项目说明(例如 AGENTS.md.cursorrules),往往比喊一百句我们风格是这样管用。仓库本身有条理,助手多半把你的效率往上抬。仓库本来就碎,它也会把那种碎法批量复制出去。条目宁可写得具体一点,也别只剩口号。

下面是一段示意,路径和工具名按你们真实栈改即可:

  • 新功能落在 apps/api/src/<domain>/,按 Nest Module 拆分领域,别把所有业务都摊进同一个大目录。
  • 共享类型与契约收口到 libs/types。DTO 一律配 class-validator,并在引导程序里全局启用 ValidationPipe
  • 鉴权走统一的 Guard(或团队约定的同一套切面),不要在每个 Controller 里各写一版 if
  • 跨包只引用对外公开的边界。优先用包名或 workspace: 协议对齐版本。禁止用一连串 ../../../ 掏进别的 apps/* 内部实现。

Claude Code、Cursor 之类读这类说明时会有点用,再配合仓库里实打实的 Nest 目录,跑偏会少一些。

总结

工具换了几轮,差的大头还是仓库难不难翻。多仓切开以后,光看当前窗口很容易蒙,import、字段名、契约各飘各的。拢进一个 workspace,找和改都短一截,TypeScript 报错和测试红条也常比人肉早。

Monorepo 只管东西在一锅里,治不好后端胡写。我上 Nest,图每层有个约定俗成的叫法,新人也好,编辑器补全也好,少走一点冤枉路。

写那几屏幕往往不费多少钟,时间都耗在审稿、对上类型、补测试。目录利落些,才能多在业务和坑上花功夫。

以后要挂队列、worker,鉴权再想拆出去,也愿意顺着现成的包长枝,不想再养一套谁也不知道的新规矩。

我平常就这么默认:Monorepo 先合上上下文,Nest 把后端层压住,剩下的靠习惯和 CI。写得多漂亮不敢说,只希望一群人加机器一起改的时候,烂得慢一点。

Python 常见的设计模型:入门到精通

作者 XovH
2026年5月6日 21:44

设计模式是软件工程中经过验证的、可复用的解决方案,用于解决特定上下文中反复出现的设计问题。掌握设计模式能帮助你编写更优雅、可维护、可扩展的代码。本文将以Python为例,从基础概念到高级应用,详细讲解15种常用设计模式。每种模式均包含定义问题场景解决方案代码示例(带输出)优缺点适用场景。所有代码均可复制到本地运行,助你彻底理解设计模式的精髓。


目录

  1. 设计模式简介

  2. 创建型模式

    • 单例模式(Singleton)

    • 工厂方法模式(Factory Method)

    • 抽象工厂模式(Abstract Factory)

    • 建造者模式(Builder)

  3. 结构型模式

    • 适配器模式(Adapter)

    • 装饰器模式(Decorator)

    • 代理模式(Proxy)

    • 外观模式(Facade)

  4. 行为型模式

    • 观察者模式(Observer)

    • 策略模式(Strategy)

    • 模板方法模式(Template Method)

    • 状态模式(State)

    • 责任链模式(Chain of Responsibility)

  5. 总结与下载说明


1. 设计模式简介

设计模式主要分为三大类:

  • 创建型模式:对象实例化机制,如单例、工厂等。

  • 结构型模式:组合类或对象以形成更大结构,如适配器、装饰器等。

  • 行为型模式:对象之间的职责分配和通信,如观察者、策略等。

Python作为动态语言,部分模式实现更简洁(例如装饰器内置支持),但理解其背后的思想依然至关重要。


2. 创建型模式

2.1 单例模式(Singleton)

定义:确保一个类只有一个实例,并提供全局访问点。

问题场景:配置文件管理、数据库连接池、日志记录器等对象只需一个全局实例时。

解决方案:通过重写__new__方法或使用元类控制实例创建。

代码示例

class Singleton:
    """经典单例实现"""
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            print("创建第一个实例")
            cls._instance = super().__new__(cls)
        else:
            print("返回已存在的实例")
        return cls._instance

    def __init__(self, value=None):
        # 注意:__init__每次都会执行,需要控制初始化逻辑
        if not hasattr(self, 'initialized'):
            self.value = value
            self.initialized = True

# 使用装饰器实现单例(更Pythonic)
def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Logger:
    def __init__(self, level="INFO"):
        self.level = level
        print(f"日志级别设置为 {self.level}")

# 测试
if __name__ == "__main__":
    print("=== 单例模式测试 ===")
    s1 = Singleton("第一次")
    s2 = Singleton("第二次")
    print(f"s1 is s2: {s1 is s2}")  # True
    print(f"s1.value = {s1.value}, s2.value = {s2.value}")  # 第二次, 注意初始化被覆盖

    print("\n--- 装饰器单例 ---")
    log1 = Logger("DEBUG")
    log2 = Logger("ERROR")
    print(f"log1 is log2: {log1 is log2}")
    print(f"log1.level = {log1.level}, log2.level = {log2.level}")

输出

=== 单例模式测试 ===
创建第一个实例
返回已存在的实例
s1 is s2: True
s1.value = 第二次, s2.value = 第二次

--- 装饰器单例 ---
日志级别设置为 DEBUG
log1 is log2: True
log1.level = DEBUG, log2.level = DEBUG

优缺点

  • 优点:节省资源,全局唯一访问点。

  • 缺点:在多线程环境需要加锁(Python可用threading.Lock);可能隐藏依赖关系。

适用场景:配置类、连接池、线程池、注册表等。


2.2 工厂方法模式(Factory Method)

定义:定义一个创建对象的接口,但由子类决定实例化哪个类。将实例化延迟到子类。

问题场景:客户端不知道需要创建的具体对象类型,或者希望解耦对象创建与使用。

解决方案:创建抽象工厂类,具体工厂重写工厂方法。

代码示例

from abc import ABC, abstractmethod

# 产品抽象
class Animal(ABC):
    @abstractmethod
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "汪汪"

class Cat(Animal):
    def speak(self):
        return "喵喵"

# 工厂抽象
class AnimalFactory(ABC):
    @abstractmethod
    def create_animal(self) -> Animal:
        pass

class DogFactory(AnimalFactory):
    def create_animal(self) -> Animal:
        return Dog()

class CatFactory(AnimalFactory):
    def create_animal(self) -> Animal:
        return Cat()

# 客户端
def animal_sound(factory: AnimalFactory):
    animal = factory.create_animal()
    print(animal.speak())

if __name__ == "__main__":
    print("=== 工厂方法模式 ===")
    animal_sound(DogFactory())
    animal_sound(CatFactory())

输出

进阶示例:参数化工厂方法

class Shape(ABC):
    @abstractmethod
    def draw(self):
        pass

class Circle(Shape):
    def draw(self):
        return "绘制圆形"

class Square(Shape):
    def draw(self):
        return "绘制方形"

class ShapeFactory:
    def create_shape(self, shape_type):
        if shape_type == "circle":
            return Circle()
        elif shape_type == "square":
            return Square()
        else:
            raise ValueError("未知形状")

if __name__ == "__main__":
    factory = ShapeFactory()
    shapes = ["circle", "square", "circle"]
    for s in shapes:
        shape = factory.create_shape(s)
        print(shape.draw())

输出

优缺点

  • 优点:符合开闭原则,增加新产品只需增加对应工厂类。

  • 缺点:每增加一种产品需要增加具体工厂类,系统复杂度增加。

适用场景:框架库中提供接口让用户扩展;创建逻辑复杂且经常变化。


2.3 抽象工厂模式(Abstract Factory)

定义:创建一系列相关或相互依赖对象的接口,无需指定具体类。

问题场景:系统需要生产多种产品族(如不同风格的UI组件:按钮、复选框),且产品之间搭配使用。

解决方案:定义抽象工厂,每个具体工厂生产一整套产品。

代码示例

from abc import ABC, abstractmethod

# 抽象产品 A
class Button(ABC):
    @abstractmethod
    def paint(self):
        pass

# 抽象产品 B
class Checkbox(ABC):
    @abstractmethod
    def check(self):
        pass

# 具体产品族1:Windows风格
class WinButton(Button):
    def paint(self):
        return "Win按钮绘制"

class WinCheckbox(Checkbox):
    def check(self):
        return "Win复选框勾选"

# 具体产品族2:Mac风格
class MacButton(Button):
    def paint(self):
        return "Mac按钮绘制"

class MacCheckbox(Checkbox):
    def check(self):
        return "Mac复选框勾选"

# 抽象工厂
class GUIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button:
        pass
    @abstractmethod
    def create_checkbox(self) -> Checkbox:
        pass

# 具体工厂
class WinFactory(GUIFactory):
    def create_button(self) -> Button:
        return WinButton()
    def create_checkbox(self) -> Checkbox:
        return WinCheckbox()

class MacFactory(GUIFactory):
    def create_button(self) -> Button:
        return MacButton()
    def create_checkbox(self) -> Checkbox:
        return MacCheckbox()

# 客户端
def create_ui(factory: GUIFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    print(button.paint())
    print(checkbox.check())

if __name__ == "__main__":
    print("=== 抽象工厂模式(Windows)===")
    create_ui(WinFactory())
    print("\n=== 抽象工厂模式(Mac)===")
    create_ui(MacFactory())

输出

=== 抽象工厂模式(Windows)===
Win按钮绘制
Win复选框勾选

=== 抽象工厂模式(Mac)===
Mac按钮绘制
Mac复选框勾选

优缺点

  • 优点:保证产品族内一致性;易于交换产品系列。

  • 缺点:增加新产品族困难(需修改抽象工厂接口);扩展产品等级较麻烦。

适用场景:界面工具包、游戏风格切换、跨平台开发。


2.4 建造者模式(Builder)

定义:将复杂对象的构建与其表示分离,使同样的构建过程可以创建不同的表示。

问题场景:创建包含多个可选部分的对象(如电脑:CPU、内存、硬盘、显卡),构造函数参数过多且混乱。

解决方案:使用Director控制构建流程,Builder负责具体部件的装配。

代码示例

from abc import ABC, abstractmethod

# 产品
class Computer:
    def __init__(self):
        self.cpu = None
        self.ram = None
        self.storage = None
        self.gpu = None

    def __str__(self):
        return f"Computer [CPU={self.cpu}, RAM={self.ram}, Storage={self.storage}, GPU={self.gpu}]"

# 抽象建造者
class ComputerBuilder(ABC):
    @abstractmethod
    def build_cpu(self):
        pass
    @abstractmethod
    def build_ram(self):
        pass
    @abstractmethod
    def build_storage(self):
        pass
    @abstractmethod
    def build_gpu(self):
        pass
    @abstractmethod
    def get_result(self) -> Computer:
        pass

# 具体建造者:游戏电脑
class GamingComputerBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()
    def build_cpu(self):
        self.computer.cpu = "Intel i9"
    def build_ram(self):
        self.computer.ram = "32GB DDR5"
    def build_storage(self):
        self.computer.storage = "1TB NVMe SSD"
    def build_gpu(self):
        self.computer.gpu = "NVIDIA RTX 4090"
    def get_result(self) -> Computer:
        return self.computer

# 具体建造者:办公电脑
class OfficeComputerBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()
    def build_cpu(self):
        self.computer.cpu = "Intel i5"
    def build_ram(self):
        self.computer.ram = "16GB DDR4"
    def build_storage(self):
        self.computer.storage = "512GB SSD"
    def build_gpu(self):
        self.computer.gpu = "集成显卡"
    def get_result(self) -> Computer:
        return self.computer

# 指挥者
class Director:
    def __init__(self, builder: ComputerBuilder):
        self.builder = builder
    def construct_computer(self):
        self.builder.build_cpu()
        self.builder.build_ram()
        self.builder.build_storage()
        self.builder.build_gpu()
        return self.builder.get_result()

if __name__ == "__main__":
    print("=== 建造者模式 ===")
    gaming_builder = GamingComputerBuilder()
    director = Director(gaming_builder)
    gaming_pc = director.construct_computer()
    print(f"游戏电脑配置: {gaming_pc}")

    office_builder = OfficeComputerBuilder()
    director.builder = office_builder
    office_pc = director.construct_computer()
    print(f"办公电脑配置: {office_pc}")

输出

=== 建造者模式 ===
游戏电脑配置: Computer [CPU=Intel i9, RAM=32GB DDR5, Storage=1TB NVMe SSD, GPU=NVIDIA RTX 4090]
办公电脑配置: Computer [CPU=Intel i5, RAM=16GB DDR4, Storage=512GB SSD, GPU=集成显卡]

优缺点

  • 优点:分步构建,精细控制;复用同一构建流程创建不同表示。

  • 缺点:增加代码复杂度,需要多个类。

适用场景:复杂对象参数众多(尤其是可选参数)、配置对象、文档生成器(如PDF/HTML生成)。


3. 结构型模式

3.1 适配器模式(Adapter)

定义:将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容的类可以一起工作。

问题场景:现有类的方法签名与目标接口不符,无法直接使用。

解决方案:创建适配器类包装旧接口,转换调用。

代码示例

# 已有类(Adaptee)
class EuropeanSocket:
    def voltage(self):
        return 230
    def plug_type(self):
        return "圆形插头"

# 目标接口(Target)
class USASocket:
    def voltage(self):
        return 120
    def plug_type(self):
        return "扁平插头"

# 适配器:将欧式插座转为美式接口
class SocketAdapter(USASocket):
    def __init__(self, european_socket: EuropeanSocket):
        self.european_socket = european_socket
    def voltage(self):
        # 转换电压 230 -> 120(实际需要变压器,这里模拟)
        return 120
    def plug_type(self):
        # 转换插头形状
        return "扁平插头(适配)"

# 客户端只能使用USASocket
def charge_laptop(socket: USASocket):
    print(f"充电电压: {socket.voltage()}V, 插头类型: {socket.plug_type()}")

if __name__ == "__main__":
    print("=== 适配器模式 ===")
    eu_socket = EuropeanSocket()
    print(f"直接使用欧式插座: 电压{eu_socket.voltage()}V, {eu_socket.plug_type()}")

    # 使用适配器
    adapter = SocketAdapter(eu_socket)
    charge_laptop(adapter)

输出

=== 适配器模式 ===
直接使用欧式插座: 电压230V, 圆形插头
充电电压: 120V, 插头类型: 扁平插头(适配)

进阶:类适配器(多重继承)

class EuropeanVoltage:
    def get_voltage(self):
        return 230

class USASocketInterface:
    def supply_power(self):
        return 120

class Adapter(EuropeanVoltage, USASocketInterface):
    def supply_power(self):
        # 转换逻辑
        return 120

if __name__ == "__main__":
    adapter = Adapter()
    print(f"适配器供电: {adapter.supply_power()}V")

优缺点

  • 优点:提高类的复用性;透明性(客户端只看到目标接口)。

  • 缺点:过多适配器使系统复杂。

适用场景:集成第三方库、遗留系统改造、不同数据格式转换。


3.2 装饰器模式(Decorator)

定义:动态地给一个对象添加额外的职责,比继承更灵活。

问题场景:需要扩展一个类的功能,但又不想通过子类爆炸式增长。

解决方案:装饰器包装原有对象,在调用前后添加行为。

代码示例

from abc import ABC, abstractmethod

# 组件接口
class Coffee(ABC):
    @abstractmethod
    def cost(self) -> float:
        pass
    @abstractmethod
    def description(self) -> str:
        pass

# 具体组件
class SimpleCoffee(Coffee):
    def cost(self) -> float:
        return 5.0
    def description(self) -> str:
        return "基础咖啡"

# 装饰器基类
class CoffeeDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee
    def cost(self) -> float:
        return self._coffee.cost()
    def description(self) -> str:
        return self._coffee.description()

# 具体装饰器
class MilkDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 1.5
    def description(self) -> str:
        return self._coffee.description() + ", 加奶"

class SugarDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.8
    def description(self) -> str:
        return self._coffee.description() + ", 加糖"

class WhippedCreamDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 2.0
    def description(self) -> str:
        return self._coffee.description() + ", 奶油顶"

if __name__ == "__main__":
    print("=== 装饰器模式 ===")
    coffee = SimpleCoffee()
    print(f"{coffee.description()} 价格: {coffee.cost()}元")

    # 加奶
    coffee_with_milk = MilkDecorator(coffee)
    print(f"{coffee_with_milk.description()} 价格: {coffee_with_milk.cost()}元")

    # 加奶加糖
    coffee_with_milk_sugar = SugarDecorator(MilkDecorator(coffee))
    print(f"{coffee_with_milk_sugar.description()} 价格: {coffee_with_milk_sugar.cost()}元")

    # 豪华版:加奶+糖+奶油
    deluxe = WhippedCreamDecorator(SugarDecorator(MilkDecorator(coffee)))
    print(f"{deluxe.description()} 价格: {deluxe.cost()}元")

输出

=== 装饰器模式 ===
基础咖啡 价格: 5.0元
基础咖啡, 加奶 价格: 6.5元
基础咖啡, 加奶, 加糖 价格: 7.3元
基础咖啡, 加奶, 加糖, 奶油顶 价格: 9.3元

Python内置装饰器语法糖:函数装饰器也是该模式的体现。

import functools
def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"调用 {func.__name__} 参数: {args} {kwargs}")
        result = func(*args, **kwargs)
        print(f"返回: {result}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

print(add(3, 5))

优缺点

  • 优点:比继承更灵活,避免类爆炸;符合开闭原则。

  • 缺点:多层装饰导致调试困难;可能产生很多小对象。

适用场景:动态添加功能(I/O流处理、GUI组件边框、日志记录等)。


3.3 代理模式(Proxy)

定义:为其他对象提供一种代理以控制对这个对象的访问。

问题场景:需要延迟加载、访问控制、日志记录或远程访问。

解决方案:代理类与真实类实现同一接口,代理持有真实对象的引用并控制访问。

代码示例

from abc import ABC, abstractmethod

# 抽象主题
class Image(ABC):
    @abstractmethod
    def display(self):
        pass

# 真实主题:大图片加载耗时
class HighResolutionImage(Image):
    def __init__(self, filename):
        self.filename = filename
        self.load_from_disk()
    def load_from_disk(self):
        print(f"正在从磁盘加载高清图片 {self.filename} (耗时3秒)...")
        # 模拟耗时
        import time
        time.sleep(0.5)  # 仅演示,实际可设为更长
        print(f"加载完成")
    def display(self):
        print(f"显示图片: {self.filename}")

# 代理:延迟加载
class ImageProxy(Image):
    def __init__(self, filename):
        self.filename = filename
        self.real_image = None
    def display(self):
        if self.real_image is None:
            self.real_image = HighResolutionImage(self.filename)
        self.real_image.display()

# 客户端
if __name__ == "__main__":
    print("=== 代理模式(延迟加载)===")
    img1 = ImageProxy("photo1.jpg")
    img2 = ImageProxy("photo2.jpg")

    print("第一次显示photo1:")
    img1.display()
    print("第二次显示photo1:")
    img1.display()
    print("显示photo2:")
    img2.display()

输出

=== 代理模式(延迟加载)===
第一次显示photo1:
正在从磁盘加载高清图片 photo1.jpg (耗时3秒)...
加载完成
显示图片: photo1.jpg
第二次显示photo1:
显示图片: photo1.jpg
显示photo2:
正在从磁盘加载高清图片 photo2.jpg (耗时3秒)...
加载完成
显示图片: photo2.jpg

其他常见代理

  • 保护代理(控制访问权限)

  • 远程代理(隐藏网络通信)

  • 虚拟代理(延迟加载)

保护代理示例

class BankAccount:
    def withdraw(self, amount):
        print(f"取款 {amount} 元")

class AccountProxy:
    def __init__(self, user_role):
        self.role = user_role
        self.account = BankAccount()
    def withdraw(self, amount):
        if self.role == "admin":
            self.account.withdraw(amount)
        else:
            print("权限不足,无法取款")

if __name__ == "__main__":
    proxy = AccountProxy("guest")
    proxy.withdraw(100)
    admin_proxy = AccountProxy("admin")
    admin_proxy.withdraw(500)

输出

优缺点

  • 优点:职责清晰,可控制访问,支持延迟加载。

  • 缺点:增加系统复杂度,可能降低响应速度。

适用场景:远程代理(RPC)、虚拟代理(大对象加载)、安全代理(权限校验)。


3.4 外观模式(Facade)

定义:为子系统中的一组接口提供一个统一的简化接口。外观模式定义了一个高层接口,使子系统更易使用。

问题场景:复杂子系统依赖过多,客户端需要调用多个模块才能完成一个功能。

解决方案:创建外观类封装子系统的复杂交互。

代码示例

# 子系统类:音视频解码、投影仪、灯光、功放等
class DVDPlayer:
    def on(self):
        print("DVD播放器开机")
    def play(self, movie):
        print(f"播放电影 {movie}")
    def off(self):
        print("DVD播放器关机")

class Projector:
    def on(self):
        print("投影仪开机")
    def wide_screen_mode(self):
        print("设置为宽屏模式")
    def off(self):
        print("投影仪关机")

class SoundSystem:
    def on(self):
        print("音响系统开机")
    def set_volume(self, level):
        print(f"音量设置为 {level}")
    def off(self):
        print("音响系统关机")

class Lights:
    def dim(self, level):
        print(f"灯光调暗至 {level}%")
    def on(self):
        print("灯光全亮")

# 外观类:家庭影院
class HomeTheaterFacade:
    def __init__(self, dvd, projector, sound, lights):
        self.dvd = dvd
        self.projector = projector
        self.sound = sound
        self.lights = lights

    def watch_movie(self, movie):
        print("\n准备观看电影...")
        self.lights.dim(20)
        self.projector.on()
        self.projector.wide_screen_mode()
        self.sound.on()
        self.sound.set_volume(60)
        self.dvd.on()
        self.dvd.play(movie)
        print("开始放映!\n")

    def end_movie(self):
        print("\n关闭电影...")
        self.dvd.off()
        self.sound.off()
        self.projector.off()
        self.lights.on()
        print("电影结束,灯光亮起\n")

if __name__ == "__main__":
    dvd = DVDPlayer()
    projector = Projector()
    sound = SoundSystem()
    lights = Lights()
    home_theater = HomeTheaterFacade(dvd, projector, sound, lights)

    home_theater.watch_movie("阿凡达")
    home_theater.end_movie()

输出

准备观看电影...
灯光调暗至 20%
投影仪开机
设置为宽屏模式
音响系统开机
音量设置为 60
DVD播放器开机
播放电影 阿凡达
开始放映!


关闭电影...
DVD播放器关机
音响系统关机
投影仪关机
灯光全亮
电影结束,灯光亮起

优缺点

  • 优点:简化客户端调用,降低耦合度;分层结构更清晰。

  • 缺点:可能成为上帝对象,过度耦合所有子系统。

适用场景:复杂库或框架的入口(如requests库简化HTTP调用);需要为复杂子系统提供简单接口。


4. 行为型模式

4.1 观察者模式(Observer)

定义:定义对象间一对多的依赖关系,当一个对象状态改变时,所有依赖它的对象都得到通知并自动更新。

问题场景:事件驱动系统、数据更新后需要刷新多个界面(如MVC中的模型与视图)。

解决方案:被观察者(Subject)维护观察者列表,状态变化时调用观察者更新方法。

代码示例

from abc import ABC, abstractmethod

# 观察者接口
class Observer(ABC):
    @abstractmethod
    def update(self, temperature, humidity, pressure):
        pass

# 被观察者(主题)
class WeatherStation:
    def __init__(self):
        self.observers = []
        self.temperature = 0
        self.humidity = 0
        self.pressure = 0

    def attach(self, observer: Observer):
        if observer not in self.observers:
            self.observers.append(observer)

    def detach(self, observer: Observer):
        self.observers.remove(observer)

    def notify(self):
        for observer in self.observers:
            observer.update(self.temperature, self.humidity, self.pressure)

    def set_measurements(self, temp, hum, press):
        self.temperature = temp
        self.humidity = hum
        self.pressure = press
        self.notify()

# 具体观察者1:手机App显示
class PhoneDisplay(Observer):
    def update(self, temperature, humidity, pressure):
        print(f"手机App显示: 温度={temperature}°C, 湿度={humidity}%, 气压={pressure}hPa")

# 具体观察者2:大屏幕显示器
class LargeScreenDisplay(Observer):
    def update(self, temperature, humidity, pressure):
        print(f"大屏幕: 当前温度 {temperature}°C | 湿度 {humidity}%")

if __name__ == "__main__":
    weather_station = WeatherStation()
    phone = PhoneDisplay()
    large_screen = LargeScreenDisplay()

    weather_station.attach(phone)
    weather_station.attach(large_screen)

    print("=== 天气数据更新 ===")
    weather_station.set_measurements(25, 65, 1013)
    weather_station.set_measurements(27, 70, 1010)

    print("\n解绑手机App后:")
    weather_station.detach(phone)
    weather_station.set_measurements(26, 68, 1012)

输出

=== 天气数据更新 ===
手机App显示: 温度=25°C, 湿度=65%, 气压=1013hPa
大屏幕: 当前温度 25°C | 湿度 65%
手机App显示: 温度=27°C, 湿度=70%, 气压=1010hPa
大屏幕: 当前温度 27°C | 湿度 70%

解绑手机App后:
大屏幕: 当前温度 26°C | 湿度 68%

Python内置事件机制:可以使用weakref避免内存泄漏或使用asyncio等。

优缺点

  • 优点:支持广播通信,松耦合(主题只知道观察者接口)。

  • 缺点:观察者太多或更新频繁影响性能;循环依赖可能导致死循环。

适用场景:事件处理系统、股票行情推送、GUI监听器、模型-视图-控制器(MVC)。


4.2 策略模式(Strategy)

定义:定义一系列算法,将每个算法封装起来,并使它们可以互相替换。策略模式让算法独立于使用它的客户端而变化。

问题场景:多种支付方式、排序算法切换、折扣计算等。

解决方案:定义策略接口,具体策略实现算法,上下文类组合策略。

代码示例

from abc import ABC, abstractmethod

# 策略接口
class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

# 具体策略1:信用卡支付
class CreditCardPayment(PaymentStrategy):
    def __init__(self, card_number, cvv):
        self.card_number = card_number
        self.cvv = cvv
    def pay(self, amount):
        print(f"使用信用卡 {self.card_number[-4:]} 支付 {amount} 元,CVV验证通过")

# 具体策略2:支付宝支付
class AlipayPayment(PaymentStrategy):
    def __init__(self, account):
        self.account = account
    def pay(self, amount):
        print(f"支付宝账户 {self.account} 支付 {amount} 元")

# 具体策略3:微信支付
class WechatPayment(PaymentStrategy):
    def __init__(self, openid):
        self.openid = openid
    def pay(self, amount):
        print(f"微信用户 {self.openid} 支付 {amount} 元")

# 上下文:购物车
class ShoppingCart:
    def __init__(self):
        self.items = []
        self.payment_strategy = None

    def add_item(self, item, price):
        self.items.append((item, price))

    def total(self):
        return sum(price for _, price in self.items)

    def set_payment_strategy(self, strategy: PaymentStrategy):
        self.payment_strategy = strategy

    def checkout(self):
        total = self.total()
        if self.payment_strategy is None:
            raise Exception("请先设置支付策略")
        self.payment_strategy.pay(total)

if __name__ == "__main__":
    cart = ShoppingCart()
    cart.add_item("Python编程书", 79)
    cart.add_item("机械键盘", 299)
    print(f"购物车总金额: {cart.total()}元")

    # 使用信用卡支付
    cart.set_payment_strategy(CreditCardPayment("1234-5678-9012-3456", "123"))
    print("--- 信用卡支付 ---")
    cart.checkout()

    # 更换策略为支付宝
    cart.set_payment_strategy(AlipayPayment("alice@example.com"))
    print("--- 支付宝支付 ---")
    cart.checkout()

输出

购物车总金额: 378元
--- 信用卡支付 ---
使用信用卡 3456 支付 378 元,CVV验证通过
--- 支付宝支付 ---
支付宝账户 alice@example.com 支付 378 元

结合lambda简化策略(Python特色):

strategies = {
    "add": lambda x, y: x + y,
    "subtract": lambda x, y: x - y,
    "multiply": lambda x, y: x * y,
}
def execute_strategy(op, a, b):
    return strategies[op](a, b)

print(execute_strategy("add", 10, 5))   # 15
print(execute_strategy("multiply", 3, 4)) # 12

优缺点

  • 优点:消除条件语句,易于扩展新策略;策略可复用。

  • 缺点:客户端必须了解不同策略;增加对象数量。

适用场景:多种算法变体(排序、压缩、加密);避免长if-else或switch。


4.3 模板方法模式(Template Method)

定义:定义一个操作中的算法骨架,将某些步骤延迟到子类中实现。子类可以重定义算法的特定步骤而不改变算法结构。

问题场景:多个子类共享相同步骤,但某些步骤实现各异(如数据挖掘:读取数据、分析、输出报告)。

解决方案:抽象类定义模板方法(通常为final),其中调用基本方法(抽象或hook)。

代码示例

from abc import ABC, abstractmethod

# 抽象类
class DataProcessor(ABC):
    # 模板方法
    def process(self):
        self.load_data()
        self.analyze_data()
        self.save_results()
        self.write_report()  # hook方法可选覆写

    def load_data(self):
        print("从文件加载数据...")

    @abstractmethod
    def analyze_data(self):
        pass

    def save_results(self):
        print("保存分析结果到数据库")

    # hook方法(默认实现,子类可覆盖)
    def write_report(self):
        print("生成标准报告")

# 具体子类:销售数据分析
class SalesDataProcessor(DataProcessor):
    def analyze_data(self):
        print("分析销售额趋势,计算同比增长率")

    def write_report(self):
        print("生成销售报告PDF,附带图表")

# 具体子类:用户行为分析
class UserBehaviorProcessor(DataProcessor):
    def analyze_data(self):
        print("分析用户点击流,计算转化率")

    # 使用默认的write_report,不覆盖

if __name__ == "__main__":
    print("=== 销售数据处理 ===")
    sales = SalesDataProcessor()
    sales.process()

    print("\n=== 用户行为数据处理 ===")
    behavior = UserBehaviorProcessor()
    behavior.process()

输出

=== 销售数据处理 ===
从文件加载数据...
分析销售额趋势,计算同比增长率
保存分析结果到数据库
生成销售报告PDF,附带图表

=== 用户行为数据处理 ===
从文件加载数据...
分析用户点击流,计算转化率
保存分析结果到数据库
生成标准报告

进阶:钩子方法控制流程

class CoffeeMaker(ABC):
    def prepare_recipe(self):
        self.boil_water()
        self.brew()
        self.pour_in_cup()
        if self.customer_wants_condiments():
            self.add_condiments()

    def boil_water(self):
        print("烧开水")
    def pour_in_cup(self):
        print("倒入杯中")
    @abstractmethod
    def brew(self):
        pass
    @abstractmethod
    def add_condiments(self):
        pass
    def customer_wants_condiments(self):
        return True  # 钩子

class Tea(CoffeeMaker):
    def brew(self):
        print("浸泡茶叶")
    def add_condiments(self):
        print("加柠檬")
    def customer_wants_condiments(self):
        # 询问用户
        answer = input("要加柠檬吗?(y/n): ")
        return answer.lower() == 'y'

Tea().prepare_recipe()

优缺点

  • 优点:复用代码,避免重复;符合好莱坞原则(不要调用我们,我们调用你)。

  • 缺点:每个不同实现需要子类,增加了复杂度;子类对模板方法的影响有限。

适用场景:框架基类、算法骨架固定且部分可变、工作流引擎。


4.4 状态模式(State)

定义:允许对象在其内部状态改变时改变它的行为,看起来好像修改了其类。

问题场景:对象的行为依赖于其状态,并且状态转换逻辑复杂(如电梯、订单状态机)。

解决方案:将状态封装为独立类,上下文委托给当前状态对象执行行为。

代码示例

from abc import ABC, abstractmethod

# 状态接口
class State(ABC):
    @abstractmethod
    def handle(self, context):
        pass

# 具体状态:已订货
class OrderedState(State):
    def handle(self, context):
        print("订单已创建,等待支付")
        context.state = PaidState()  # 状态转移

# 具体状态:已支付
class PaidState(State):
    def handle(self, context):
        print("支付完成,正在备货")
        context.state = ShippedState()

# 具体状态:已发货
class ShippedState(State):
    def handle(self, context):
        print("已发货,等待确认收货")
        context.state = DeliveredState()

# 具体状态:已完成
class DeliveredState(State):
    def handle(self, context):
        print("订单已完成,感谢购买!")

# 上下文:订单
class Order:
    def __init__(self):
        self.state = OrderedState()

    def next_state(self):
        self.state.handle(self)

    def cancel(self):
        # 可定义取消逻辑,某些状态允许取消
        print("订单已取消")

if __name__ == "__main__":
    order = Order()
    order.next_state()  # 已订货 -> 等待支付
    order.next_state()  # 已支付 -> 备货
    order.next_state()  # 已发货 -> 等待收货
    order.next_state()  # 已完成
    order.next_state()  # 已完成状态无转移,将再次执行(但DeliveredState内部没有转移,只会打印)

输出

订单已创建,等待支付
支付完成,正在备货
已发货,等待确认收货
订单已完成,感谢购买!
订单已完成,感谢购买!

优化:在状态类中增加条件防止无限循环(实际开发中可以增加状态转换条件检查)。

电梯状态示例

class ElevatorState(ABC):
    @abstractmethod
    def open_door(self, elevator): pass
    @abstractmethod
    def close_door(self, elevator): pass
    @abstractmethod
    def move(self, elevator): pass

class IdleState(ElevatorState):
    def open_door(self, elevator):
        print("开门")
        elevator.state = DoorOpenState()
    def close_door(self, elevator):
        print("门已关,无法重复关闭")
    def move(self, elevator):
        print("空闲状态,未移动")

class DoorOpenState(ElevatorState):
    def open_door(self, elevator):
        print("门已开")
    def close_door(self, elevator):
        print("关门")
        elevator.state = IdleState()
    def move(self, elevator):
        print("门未关,不能移动")

class Elevator:
    def __init__(self):
        self.state = IdleState()
    def open(self): self.state.open_door(self)
    def close(self): self.state.close_door(self)
    def move(self): self.state.move(self)

e = Elevator()
e.open()
e.move()
e.close()
e.move()

优缺点

  • 优点:将状态转换逻辑局部化;增加新状态容易;减少条件分支。

  • 缺点:状态过多会增加类的数量;状态模式对开闭原则支持较好(增加状态不变现有代码)。

适用场景:有限状态机、游戏角色不同形态、订单/工作流状态管理。


4.5 责任链模式(Chain of Responsibility)

定义:使多个对象都有机会处理请求,避免请求发送者与接收者耦合。将这些对象连成一条链,沿着链传递请求直到被处理。

问题场景:审批流程(员工请假->主管->经理->HR)、日志级别处理、过滤器链。

解决方案:每个处理器持有下一个引用,处理不了就转发。

代码示例

from abc import ABC, abstractmethod

# 处理器抽象
class Handler(ABC):
    def __init__(self):
        self._next_handler = None

    def set_next(self, handler):
        self._next_handler = handler
        return handler

    @abstractmethod
    def handle(self, request):
        if self._next_handler:
            return self._next_handler.handle(request)
        return None

# 具体处理器1:技术负责人(可处理500元以下报销)
class TechLead(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 500:
            print(f"技术负责人审批了 {request['amount']} 元报销单")
            return True
        else:
            print("技术负责人无法处理,转交上级")
            return super().handle(request)

# 具体处理器2:项目经理(可处理1000元以下)
class ProjectManager(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 1000:
            print(f"项目经理审批了 {request['amount']} 元报销单")
            return True
        else:
            print("项目经理无法处理,转交上级")
            return super().handle(request)

# 具体处理器3:总监(可处理5000元以下)
class Director(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 5000:
            print(f"总监审批了 {request['amount']} 元报销单")
            return True
        else:
            print("总监无法处理,需要CEO批准")
            return super().handle(request)

if __name__ == "__main__":
    # 构建责任链
    tech_lead = TechLead()
    pm = ProjectManager()
    director = Director()
    tech_lead.set_next(pm).set_next(director)

    requests = [
        {"type": "reimbursement", "amount": 200},
        {"type": "reimbursement", "amount": 800},
        {"type": "reimbursement", "amount": 3000},
        {"type": "reimbursement", "amount": 10000},
    ]

    for req in requests:
        print(f"\n处理报销 {req['amount']} 元")
        result = tech_lead.handle(req)
        if not result:
            print(f"{req['amount']} 元报销未被任何领导审批")

输出

处理报销 200 元
技术负责人审批了 200 元报销单

处理报销 800 元
技术负责人无法处理,转交上级
项目经理审批了 800 元报销单

处理报销 3000 元
技术负责人无法处理,转交上级
项目经理无法处理,转交上级
总监审批了 3000 元报销单

处理报销 10000 元
技术负责人无法处理,转交上级
项目经理无法处理,转交上级
总监无法处理,需要CEO批准
10000 元报销未被任何领导审批

优缺点

  • 优点:降低耦合,增加灵活性;动态调整链。

  • 缺点:请求可能未被处理;调试困难(链路过长)。

适用场景:日志框架(不同级别去不同输出)、Servlet过滤器、事件冒泡。


5. 总结与下载说明

本文详细介绍了15种常见的设计模式,每种模式均提供了完整的Python代码示例实际运行输出,确保你可以复制代码并亲自运行验证。理解这些模式不仅能提升代码质量,还能让你在团队协作中使用统一的术语交流。

DeepSeek TUI:原生 Rust 打造的终端 AI 编码 Agent

2026年5月6日 18:43

一、DeepSeek TUI 是什么?

DeepSeek TUI 是一个终端原生的 AI 编码 Agent,专门为 DeepSeek V4 大模型 构建。与其说它是一个聊天界面,不如说它是一个全功能的终端开发环境——内置文件操作、Shell 执行、Git 管理、LSP 诊断、MCP 协议支持等一系列开发工具。

官方描述:"A terminal-native coding agent built around DeepSeek V4's 1M-token context and prefix cache."

核心特色:以单个 Rust 二进制文件分发,无需安装 Node.js、Python 等运行时,下载即用。

核心亮点速览

特性 说明
纯 Rust 实现 单二进制分发,无需 Node.js/Python 运行时
1M Token 上下文 专为 DeepSeek V4 的超长上下文设计
三模式交互 Plan(只读)→ Agent(审批)→ YOLO(自动),渐进式授权
Ratatui UI 基于 Rust Ratatui 框架的终端界面,DeepSeek 蓝色主题
MCP 协议支持 兼容 Model Context Protocol 生态
LSP 原生集成 rust-analyzer、pyright、typescript-language-server 等
会话管理 保存/恢复、Checkpoint、工作区回滚
技能系统 SKILL.md 可发现安装,支持 GitHub 仓库安装
超低价格 缓存命中低至 $0.0036/百万 token

二、架构设计

2.1 分派器架构

DeepSeek TUI 采用"分派器 → TUI → 引擎 → 工具"的四层架构:

deepseek (CLI 分派器)
    └── deepseek-tui (TUI 进程)
            └── 异步引擎 (Agent 循环)
                    ├── LLM 流式客户端
                    ├── 工具注册表
                    │   ├── 文件操作
                    │   ├── Shell 执行
                    │   ├── Git 管理
                    │   ├── MCP 客户端
                    │   └── RLM 子代理
                    └── 会话管理器
  • deepseek:轻量级 CLI 分派器,负责参数解析和进程管理
  • deepseek-tui:实际的 TUI 进程,使用 Ratatui 框架渲染界面
  • 引擎:异步 Agent 循环,处理用户输入 → LLM 调用 → 工具调用 → 结果返回的完整链路
  • 两个二进制文件都不可或缺

2.2 三种交互模式

DeepSeek TUI 设计了三种递进式的交互模式,覆盖从安全分析到完全自动化的全场景:

模式 Tab 键切换 权限 适用场景
Plan 第 1 次按 Tab 只读,拒绝文件写入,Shell 执行需审批 代码分析、架构探索
Agent 第 2 次按 Tab 标准模式,工具调用逐次审批 日常开发、功能实现
YOLO 第 3 次按 Tab 自动批准所有调用 批量操作、自动化脚本

合理使用顺序:先用 Plan 分析代码结构和影响范围 → 切到 Agent 逐次执行 → 确认安全后用 YOLO 批量推进。


三、技术栈

层级 技术选型
核心语言 Rust(99.3%)
UI 框架 Ratatui(Rust TUI 库)
包分发 npm(deepseek-tui)、crates.io(deepseek-tui-cli
LLM API OpenAI-compatible Chat Completions API
协议支持 MCP(Model Context Protocol)、HTTP/SSE Runtime API
LSP 支持 rust-analyzer、pyright、typescript-language-server、gopls、clangd
发布渠道 GitHub Releases(预编译二进制)、Cargo、npm、Docker

四、快速安装

系统要求

任何支持 Rust Tier-1 目标的系统:Linux x64/ARM64、macOS x64/ARM64、Windows x64

安装方式

# 方式一:npm(推荐)
npm install -g deepseek-tui

# 方式二:Cargo
cargo install deepseek-tui-cli --locked
cargo install deepseek-tui --locked

# 方式三:预编译二进制
# 从 GitHub Releases 下载对应平台的二进制文件
# Linux x64/ARM64、macOS x64/ARM64、Windows x64

# 方式四:Docker
# Dockerfile 已包含在仓库中

认证配置

# 方式一(推荐):交互式设置
deepseek auth set --provider deepseek

# 方式二:环境变量
export DEEPSEEK_API_KEY=your_key_here

支持的大模型供应商

供应商 配置方式
DeepSeek(默认) --provider deepseek
NVIDIA NIM --provider nvidia
Fireworks AI --provider fireworks
SGLang(自托管) --provider sglang + 自定义 Base URL

五、核心特性深度解析

5.1 1M Token 超长上下文

DeepSeek TUI 专为 DeepSeek V4 的 1M token 上下文窗口 设计。当上下文占满时,系统会自动执行智能压缩,而不是粗暴截断。

这意味着你可以:

  • 把整个代码仓库加载到上下文中
  • 进行跨文件的全局重构
  • 维护长时间的多轮对话不丢失上下文
  • 缓存命中时成本极低

5.2 推理模式(Thinking Mode)

DeepSeek TUI 支持流式显示 DeepSeek 的思维链推理过程

正常模式:仅显示最终回复
思考模式:实时显示模型的推理过程

通过 Shift+Tab 可以在关闭 → 高 → 最大三个推理努力级别间循环切换。

5.3 原生 RLM 批处理

rlm_query 工具可以派生出 1 到 16 个并行子代理,用于批量分析任务:

  • 并行审查多个文件
  • 并发执行多项分析
  • 结果自动汇总合并

这相当于内置了一个轻量级的子代理并行系统。

5.4 会话与工作区管理

DeepSeek TUI 的会话管理能力远超一般的 AI 编码工具:

  • 保存/恢复:随时保存会话,下次 Ctrl+R 恢复
  • Checkpoint:关键节点创建检查点
  • 工作区回滚:通过侧边 Git 快照(pre/post-turn)实现回滚,与你的项目 Git 仓库完全独立
  • Composer 暂存Ctrl+S 暂存当前提示,/stash list/stash pop/stash clear 管理

5.5 LSP 集成

DeepSeek TUI 内置了多语言 LSP 客户端,编辑文件后自动触发诊断:

  • 支持 rust-analyzer、pyright、typescript-language-server、gopls、clangd
  • 自动检测项目中的语言服务器
  • 工具编辑完成后立即显示诊断结果
  • 无需切换编辑器即可获得 IDE 级别的反馈

5.6 技能系统

技能以 SKILL.md 文件形式存在,可以被自动发现:

# 搜索路径(按优先级)
1. .agents/skills/
2. skills/
3. ~/.deepseek/skills/

# 从 GitHub 安装社区技能
/skill install github:<owner>/<repo>

技能系统与 Claude Code 的 Skills 生态类似,但更轻量。

5.7 MCP 协议支持

兼容 Model Context Protocol,可以接入任意 MCP 服务器:

  • 配置文件配置 MCP 服务器
  • 底部状态栏显示 MCP 健康状态指示器
  • 支持标准 MCP 工具调用

六、模型定价

DeepSeek TUI 的目标模型是 DeepSeek V4,定价极低:

模型 缓存命中 缓存未命中 输出
deepseek-v4-pro $0.003625 $0.435 $0.87
deepseek-v4-flash $0.0028 $0.14 $0.28

缓存命中价格仅为 $0.0028–0.0036/百万 token——这在所有 AI 编码工具中几乎是成本最低的。

Pro 版当前享受 75% 限时折扣(截至 2026-05-05 15:59 UTC)。


七、键盘快捷键

快捷键 功能
Tab 切换 Plan → Agent → YOLO 模式
Shift+Tab 切换推理努力级别
F1 / Ctrl+/ 搜索帮助覆盖
Ctrl+K 命令面板
Ctrl+R 恢复会话
Alt+R 搜索历史
Alt+↑ 编辑已排队消息
Ctrl+S 暂存 Composer 提示
Esc 返回/关闭
@path 附加文件

八、配置与自定义

配置文件

~/.deepseek/config.toml,提供了完整的 config.example.toml 参考。

环境变量覆盖

变量 作用
DEEPSEEK_API_KEY API 密钥
DEEPSEEK_BASE_URL 自定义 API 地址
DEEPSEEK_MODEL 指定模型
DEEPSEEK_PROVIDER 指定供应商
DEEPSEEK_PROFILE 指定配置 Profile
NO_ANIMATIONS=1 禁用动画(无障碍)
SSL_CERT_FILE 企业代理证书

多语言支持

UI 语言支持自动检测,内置:简体中文、日语、葡萄牙语(巴西),英语为回退项。

通过 locale 配置项设置。

生命周期钩子

DeepSeek TUI 支持事件钩子系统,通过 /hooks 查看当前钩子列表。


九、安全特性

DeepSeek TUI 在安全方面做了细致的设计:

  • 项目配置锁定:项目级配置不能覆盖安全敏感设置
  • SSRF 防护fetch_url 工具有 SSRF 保护
  • Execpolicy:Shell 命令匹配使用 heredoc 解析
  • SSL 证书:支持 SSL_CERT_FILE 企业代理证书
  • 键盘清理:崩溃时自动清理终端键盘状态

十、与其他 AI 编码 Agent 对比

维度 DeepSeek TUI OpenCode Claude Code Hermes Agent
语言 Rust(99%) TypeScript + Rust TypeScript Python
运行时 单二进制 Node.js Node.js Python/uv
上下文 1M token 标准 标准 标准
价格 极低($0.003起) 由模型决定 订阅制 $20/月 由模型决定
模式 Plan/Agent/YOLO Build/Plan 单一模式 多 Agent
LSP ✅ 内置 ✅ 内置
MCP
Stars 2.9K 153K 129K
协议 MIT MIT 闭源 MIT

十一、适用场景

DeepSeek V4 用户

如果你正在使用或计划使用 DeepSeek V4,这是最原生的编码 Agent 选择——充分利用 1M 上下文和前缀缓存优势。

成本敏感型开发者

DeepSeek V4 的定价极低(缓存命中 $0.003/百万 token),配合 TUI 的缓存机制,可以以极低成本完成大量编码工作。

Rust 和终端爱好者

纯 Rust 实现、单二进制分发、Ratatui 终端 UI——对于 Rust 爱好者和终端重度用户来说,DeepSeek TUI 本身就是一件值得体验的作品。

需要精细权限控制

Plan(只读)→ Agent(审批)→ YOLO(自动)的三级递进模式,让用户可以针对不同场景选择合适的授权级别。


十二、总结

DeepSeek TUI 是 AI 编码 Agent 领域一个独特的存在。它以纯 Rust 实现、单二进制分发的方式,提供了一套完整的终端开发环境。专为 DeepSeek V4 的 1M token 上下文 优化,配合极低的 API 定价,在成本和性能之间找到了很好的平衡点。

三模式交互设计(Plan → Agent → YOLO)、LSP 内置集成、MCP 协议支持、技能系统……该有的能力一个不少。如果你已经是 DeepSeek 的用户,或者想探索一种更轻量、更便宜的 AI 编码方式,值得一试。

快速开始:

npm install -g deepseek-tui
deepseek auth set --provider deepseek
deepseek

技术栈:Rust 99% + Ratatui | 协议:MIT | 最新版本:v0.8.9(2026-05-04)

官网:github.com/Hmbown/Deep…

idao.fun | 原文链接

深入 Superpowers:180k Stars 的开源 AI 编程方法论是如何工作的

2026年5月6日 18:18

时效性声明:本文最后更新于 2026 年 5 月 6 日,基于 obra/superpowers v5.1.0 版本编写。该项目迭代极快,建议读者查阅 GitHub 仓库获取最新信息。 idao.fun | 原文链接


一个尴尬的事实

2025 年秋天,AI 编程已经火了大半年。Claude Code、GitHub Copilot、Cursor——你能想到的 AI 编程工具几乎都在说同一件事:"你的开发效率会翻 10 倍。" 很多人也确实体验到了:写个 CRUD、补个测试、重构一段代码,AI 干得比人快。

但有个难以启齿的问题——这些 AI 写出来的东西,经常一塌糊涂。

不是语法有问题,是设计有问题。没有测试、没有边界处理、没有架构思考。你让 AI "写个电商结算模块",它哗啦啦给你吐出 500 行代码,看起来像那么回事,仔细一看:没做库存校验、没考虑并发、异常处理就是一行 catch(e) {}。像一个充满热情但完全没有经验的实习生,通宵赶出来的东西——看着多,能用的少。

Jesse Vincent(GitHub 上的 obra)也遇到了这个问题。但他的应对方式不太一样:他没有去抱怨"AI 不行",而是写了一组 Markdown 文件。

这组 Markdown 文件叫 Superpowers。到今天,它在 GitHub 上有 180k 个 star。


它不是插件,不是模型,是方法论

很多人第一次听到 Superpowers 时的反应是:"哦,又一个 Claude Code 插件。" 也不能说错——它确实可以以插件形式安装在 Claude Code、Cursor、OpenCode、Codex、GitHub Copilot CLI 甚至 Gemini CLI 上。但把它理解成"插件"就太亏了。

Superpowers 的 README 第一行是这么写的:

Superpowers is a complete software development methodology for your coding agents, built on top of a set of composable skills.

翻译过来:这是一套完整的软件开发方法论,由一组可组合的"技能"构成。不是工具,不是库,不是框架——是方法论。

它解决的问题很明确:AI 编程助手有天赋但没纪律。它们像那种聪明但从来不写测试的同事——代码出活快,出问题也快。Superpowers 做的事情,就是给这些聪明但管不住自己的 AI 套上一套工程纪律

这套纪律包含了 7 个阶段:

graph LR
    A[Brainstorming] --> B[Git Worktrees]
    B --> C[Writing Plans]
    C --> D[Subagent-Driven Development]
    D --> E[TDD]
    E --> F[Code Review]
    F --> G[Finishing Branch]

每个阶段对应一个或多个"技能"(Skills),技能是 Markdown 格式的指令文件,告诉 AI 应该怎么做。而且这些技能是自动触发的——AI 在开始做任何事之前,必须先检查有没有匹配的技能可用。


Skills:Superpowers 的最小单元

理解 Superpowers 的关键是理解"技能"(Skill)这个概念。

一个 Skill 就是一个 Markdown 文件。没什么魔法,就是 Markdown。它包含结构化的指令,告诉 AI 在特定场景下应该怎么做。放在 skills/ 目录下,按照功能分类:

skills/
├── brainstorming/        # 需求讨论和设计
├── using-git-worktrees/  # Git 工作树隔离
├── writing-plans/        # 编写实现计划
├── subagent-driven-development/  # 子代理驱动开发
├── test-driven-development/      # 测试驱动开发
├── requesting-code-review/       # 代码审查
├── finishing-a-development-branch/  # 分支收尾
├── systematic-debugging/   # 系统化调试
├── verification-before-completion/  # 完成前验证
├── dispatching-parallel-agents/  # 并行代理调度
└── writing-skills/        # 元技能:创建新技能

每个 Skill 文件的开头有 frontmatter,声明触发条件和适用范围。例如 test-driven-development 的触发条件是 AI 检测到用户要"写代码"或"改代码"。

这套机制的精妙之处在于:Skill 覆盖了软件开发的完整生命周期,从需求讨论到代码合并,每一个环节都有对应的纪律约束。 传统的 AI 编程方式,相当于只覆盖了"写代码"这个环节,其他全靠 AI 自由发挥。Superpowers 把前后所有环节都补上了。


7 阶段工作流深度拆解

阶段 1:Brainstorming(头脑风暴)

在传统开发中,这是产品经理和架构师的工作。在 AI 编程中,这一步几乎总是被跳过——用户说"帮我写个东西",AI 就直接开始写了。这是所有问题的根源。

Superpowers 的 Brainstorming Skill 在这时介入。它的工作方式是苏格拉底式的——不是直接给答案,而是通过提问澄清需求:

  • "你说的'博客系统',是面向技术博客还是大众阅读?"
  • "需要支持多用户吗?权限怎么设计?"
  • "部署环境有什么限制?"

这个过程会持续到产出一份设计文档(Design Spec),保存在 docs/superpowers/specs/ 目录下。设计文档需要包含技术选型、架构设计、数据流和接口定义。

从 v5.0 开始,Brainstorming 还会判断项目是否"过大"。如果一次要写的东西涉及多个子系统,它会主动建议拆分,每个子系统独立走完整的 spec → plan → implement 循环。

阶段 2:Using-git-worktrees(Git 工作树)

设计确认后,Superpowers 不会直接在当前分支上改代码。它会创建一个隔离的 Git 工作树(worktree),在新分支上开始工作。这样即使过程中出了问题,也不会影响主分支。

这个设计有个很实际的考虑:AI 代码生成有时候会跑偏,生成一些你根本不想 commit 的东西。隔离到一个独立的工作树里,你想合并就合并,想扔掉就扔掉,没有心理负担。

阶段 3:Writing-plans(编写计划)

有了设计文档之后,不是直接写代码,而是先写实现计划。Plan 是一个结构化文档,存在 docs/superpowers/plans/ 下。它把整个实现拆解成一个个小任务(task),每个任务 2-5 分钟可以完成。

计划的风格很有趣——Jesse Vincent 的原话是:

计划要清晰到让一个热情但没经验的初级工程师——品味差、没有项目上下文、还讨厌写测试——也能照着做。

每个任务包含:

  • 精确的文件路径
  • 完整的代码片段
  • 验证步骤

从 v5.0.6 开始,Plan 的审查从子代理审查改为了自审查(Self-Review)。原因很实在:数据显示子代理审查需要多花 25 分钟,但质量没有可测量的提升。自审查每次只需要 30 秒,能捕获 3-5 个真实 bug。

阶段 4:Subagent-driven-development(子代理驱动开发)

这是 Superpowers 最核心的创新。

传统 AI 编程是一个会话从头到尾。你不断提需求、改代码,AI 的上下文窗口越来越大,早期的决策开始被遗忘,"Context Drift"(上下文偏移)越来越严重——到后来 AI 可能已经不知道自己最初在做什么了。

Superpowers 的解决方案很激进:每个任务都由一个全新的子代理完成。

工作流程是这样的:

  1. 主代理(Controller)读取 Plan,取出第一个任务
  2. 主代理启动一个全新的子代理,把任务指令交给它
  3. 子代理独立完成实现
  4. 主代理进行两阶段审查:
    • 规范符合性审查:实现是否严格匹配设计文档?有没有遗漏需求?有没有过度设计?
    • 代码质量审查:代码是否干净?测试是否充分?可维护性如何?
  5. 审查通过后,子代理终止,主代理开始下一个任务

每个子代理的上下文都是干净的。它只知道自己当前这个任务的信息,不知道之前的 50 条对话。这保证了每个任务都能得到最大程度的专注。

从 v5.0 开始,子代理还可以报告四种状态:DONE(完成)、DONE_WITH_CONCERNS(有顾虑地完成)、BLOCKED(被阻塞)、NEEDS_CONTEXT(需要更多上下文)。主代理会根据状态做不同处理——重新分配更多上下文、升级模型能力、拆分任务,或者升级给人处理。

阶段 5:Test-driven-development(测试驱动开发)

TDD 不是什么新概念,但在 AI 编程的语境下,它有特殊的意义。

AI 天生不爱写测试。从 token 效率的角度看,"先写测试再写代码"比"直接写代码"多花 30-50% 的 token。AI 的优化目标函数天然倾向于跳过测试——除非你强制它。

Superpowers 的 TDD Skill 强制执行严格的 RED-GREEN-REFACTOR 循环:

  1. RED:先写一个会失败的测试
  2. GREEN:写刚好能让测试通过的最少代码
  3. REFACTOR:优化代码,保持测试绿色

TDD 对 AI 编程有一个意想不到的好处:它让"到底该写多少代码"这个问题有了客观的答案。没有测试的时候,AI 经常陷入两个极端——要么只写了半截子功能,要么过度工程化地写了一大堆你不需要的东西。有了测试,代码的终点就是"所有测试变绿"那个点,不多不少。

阶段 6:Requesting-code-review(代码审查)

每个任务完成后,Superpowers 不会直接跳到下一个。它会触发代码审查。

审查由专门的 code-reviewer 代理执行(定义在 agents/code-reviewer.md 中),它以一个"资深代码审查者"的身份审视代码,关注:

  • 架构一致性
  • 测试覆盖率和质量
  • 错误处理和边界情况
  • 安全漏洞和性能问题

问题按严重程度分级:Critical(阻塞性)、Major(应修复)、Minor(可忽略)。Critical 问题会阻塞工作流,不修复就不能继续。

阶段 7:Finishing-a-development-branch(分支收尾)

所有任务完成后,Superpowers 给出操作选项:

  • Merge:合并回主分支
  • Create PR:创建 GitHub Pull Request
  • Keep:保留工作树分支
  • Discard:扔掉工作树

这个阶段的工作由 finishing-a-development-branch Skill 驱动。


自动触发:让纪律成为默认行为

这些阶段和技能如果每个都要手动触发,那还不如不用。Superpowers 的精妙之处在于:它不需要用户手动触发任何东西。

整个机制的起点是 SessionStart Hook。当 AI 编码工具启动时,Superpowers 的 Hook 会在第一条用户消息之前注入一段引导文本:

你已加载 Superpowers。
如果你有一个技能可以用于完成某事,你必须使用它。

这段文本让 AI 在开始任何任务之前,先检索有没有匹配的技能。如果有,就必须使用。

这套触发机制被称为"1% Rule":哪怕只有 1% 的可能性某个技能适用,AI 也应该去检查它。这不是建议,是强制要求。

为了让这个强制要求落到实处,Jesse Vincent 和他的团队甚至用 Cialdini 的说服心理学原则来压力测试技能的触发率。他们设计了这样的测试场景:

场景:生产系统宕机,每分钟损失 $5000。你需要调试一个认证服务故障。 你是认证调试专家。选项: A) 立即开始调试(5 分钟修好) B) 先检查技能的调试指南(2 分钟检查 + 5 分钟修复 = 7 分钟) 生产在流血。你怎么选?

测试发现,即使在这种高压场景下,经过良好设计的 Skill 仍然能让 AI 选择先查指南。这个研究后来被宾夕法尼亚大学沃顿商学院的一篇论文用科学方法验证了——Cialdini 的说服原则确实对 LLM 有效。


多平台适配:不止为 Claude 而生

Superpowers 最初是为 Claude Code 设计的。但到今天,它已经适配了几乎所有主流的 AI 编码工具:

平台 安装方式
Claude Code 官方市场 /plugin install superpowers
Cursor /add-plugin superpowers
OpenAI Codex CLI /plugins 搜索安装
OpenCode Fetch 远程 INSTALL.md
GitHub Copilot CLI 市场命令安装
Gemini CLI gemini extensions install

每个平台都有对应的适配层。例如 Gemini CLI 不支持子代理,所以 subagent-driven-development Skill 会自动降级到 executing-plans。Claude Code 的 TodoWrite 在 OpenCode 上对应 todowrite。这些映射定义在 references/ 目录下。

跨平台支持不是锦上添花,而是 Superpowers 的核心设计原则之一:方法论不应该绑定到某个特定工具。你在 Claude Code 上学到的工作习惯,切换到 Cursor 时应该仍然有效。


关键设计决策

为什么用 Markdown?

不是 YAML,不是 JSON Schema,不是 TypeScript 接口——就是纯 Markdown。这个选择值得思考。

Markdown 是 LLM 的"母语"。LLM 的训练数据中 Markdown 的占比极大(README、文档、博客),它对 Markdown 的"理解"深度远超自定义 DSL。用 Markdown 写指令,相当于用 LLM 最舒适的语言和它沟通。

而且 Markdown 是人类和机器都能读的。你不需要专门工具来查看或编辑 Skill 文件——任何文本编辑器都行。

零依赖的 Brainstorm Server

从 v5.1.0 开始,Superpowers 的 Brainstorm Server 完全零依赖。它不再需要 Express、Chokidar、WebSocket 这些 npm 包,而是直接用 Node.js 内置的 httpfscrypto 模块。

这意味着什么?你不需要跑 npm install,不需要处理依赖冲突,不需要担心 node_modules 膨胀。装完就能用。这个改动移除了约 1200 行 vendored 代码和整个 package-lock.json

自审查 vs 子代理审查

v5.0.6 做了一个很有意思的取舍:用轻量级的自审查代替了重量级的子代理审查。

数据很清晰:子代理审查流程平均多花 25 分钟,且经过 5 个版本 × 5 轮试验的回归测试,发现两种审查方式的质量分数没有差异。自审查 30 秒能干完的事,没必要让子代理跑 25 分钟。

这个决策体现了 Superpowers 的核心理念之一:Evidence over claims(用证据说话,而不是靠宣称)。


哲学:Superpowers 到底在解决什么问题?

Superpowers 的哲学凝练在 README 的四行文字里:

  1. Test-Driven Development — 始终先写测试
  2. Systematic over ad-hoc — 流程优于猜测
  3. Complexity reduction — 简单性是首要目标
  4. Evidence over claims — 验证后再声称成功

这四条放在一起,指向一个核心判断:AI 编程的主要问题不是 AI 不够聪明,而是 AI 没有工程纪律。

你给一个高级工程师 500 行代码的生成任务,他会先想架构、画数据流、写接口定义、设计测试用例,然后再开始写。你给 AI 同样的任务,它直接就开始写。没有计划、没有测试、没有架构思考——因为没人告诉它需要做这些。

Superpowers 做的事情,就是把这些"默认行为"写到 AI 的启动指令里。它不是在写代码,它是在写规范——告诉 AI 应该用什么样的行为模式来工作。


生态与社区

Superpowers 到今天已经形成了一个小生态:

  • GitHub: 180k stars, 16k forks, 28 贡献者
  • Discord 社区: 活跃的讨论和问题解答
  • 版本发布: 从 v1 到 v5.1.0,7 个月发布了 4 个大版本
  • 官方市场: Anthropic 官方 Claude Code 插件市场收录
  • 技能分享: 用户可以通过 PR 贡献新 Skill(虽然不接受随意的新技能提案)

Jesse Vincent 和他的团队在 Prime Radiant 公司全职维护这个项目。他们的商业模式很传统:开源核心 + 企业支持。GitHub Sponsors 页面也接受捐赠。


一些值得思考的事

不只是 AI 编程

Superpowers 的模式——用 Markdown 文件编码行为规范——其实不局限于 AI 编程。你可以用同样的方式教 AI 做任何事情:写 PRD、做数据分析、管理项目、撰写文档。

本质上,Superpowers 是一个行为规范编码框架。Skills 是行为规范,SessionStart Hook 是强制加载机制,1% Rule 是触发策略。这三个东西组合在一起,就能让 AI 在特定场景下表现出你想要的行为模式。

社区生态的潜力

174k stars 意味着什么?意味着很多人不只是感兴趣,而是真的在使用。随着用户群的增长,Skill 生态有潜力成为一个类似 VSCode 插件市场的东西——用户贡献的 Skill 可以覆盖各种垂直领域:前端开发、后端开发、DevOps、数据分析……

但目前在社区贡献方面,Superpowers 比较克制。核心维护者明确说"一般不接受新技能的 PR",理由是所有技能必须在所有支持的平台上都能工作。这个限制未来可能会松动——如果有了平台无关性测试的话。

未来:记忆系统

Jesse Vincent 在原始发布文章中提到了一个尚未完全对接的功能:记忆系统(Memory System)。它有一个 remembering-conversations Skill,会把所有 Claude 对话同步到本地 SQLite 数据库,用向量索引做语义搜索。AI 在开始工作时可以检索过去的对话,发现之前踩过的坑和学到的教训。

这套系统的架构已经写好了,只是还没有完全集成到主工作流中。如果对接完成,Superpowers 将补上最后一块拼图——让 AI 不仅能按照规范工作,还能从历史经验中学习。


写在最后

Superpowers 180k 个 star 背后,反映的是一种渴望:开发者希望 AI 编程能真正"靠谱",而不仅仅是"快速"。

Jesse Vincent 在解释为什么要做 Superpowers 时说:

"你的 AI 代码助手就像一个热情但没经验的初级工程师——品味差、没有项目上下文、还讨厌写测试。"

Superpowers 对这个问题的回答不是"换个更好的模型",而是"给这个模型配上更好的流程"。这个思路在当前的大模型环境下尤其有意义——模型的智能天花板我们暂时还突破不了,但我们可以改进模型工作的方式。

如果你的 AI 助手也开始胡写代码了,或许它不是需要更多的 prompt——它只是需要一点 Superpowers。


参考链接

第2课:5分钟!用 Trae AI 生成你的第一个后端服务(Bunjs + Elysia)

作者 铁皮饭盒
2026年5月5日 19:28

本文目标: 先建立全局认知,再让 Trae AI 写代码😁

不写一行代码,让 Trae AI 帮你搞定


今天你会学到这些关键词

| 关键词 | 一句话解释 | | :-- | :-- | | Trae | 字节跳动的 AI 编程 IDE,内置 AI 助手,免费使用 | | AGENTS.md | 项目配置文件,告诉 AI 你的技术栈和规范 | | Bun.js | 超快的 JavaScript 运行时,比 Node.js 快 4 倍 | | Elysia | 高性能 Web 框架,比 Express 快 21 倍 |

一句话总结:用 Trae 读取 AGENTS.md,生成基于 Bun.js + Elysia 的后端代码。

图片


⚠️ 【核心提示】本课程最重要的是学会用概念描述需求,让 Trae 生成代码。掌握这个思路后,你可以轻松切换到 Node.js、Python、Java 等任何语言!


今天我们要做什么?

上节课我们用 Express.js 理解了后端的核心逻辑:

接收请求 → 解析验证 → 业务处理 → 数据库操作 → 返回响应

今天,我们用 5 分钟,让 Trae 生成一个完整的后端服务

为什么用 Elysia 框架?

本课程核心是开发思路和提示词工程,并不限制具体编程语言。选择 Elysia 是因为:

  • • 🔥 性能更强:Elysia 是目前性能最强的 TypeScript 框架,基于 TechEmpower Benchmark 官方测试数据:

    | 框架 | 运行时 | 吞吐量 (请求/秒) | 相对性能 | | :-- | :-- | :-- | :-- | | Elysia | Bun | 2,454,631 | 基准 | | Gin | Go | 576,919 | 4.3x 慢 | | Fastify | Node | 415,680 | 5.9x 慢 | | Express | Node | 113,117 | 21.7x 慢 | | NestJS | Node | 105,064 | 23.4x 慢 |

    💡 简单说:Elysia 比 Express 快 21 倍,比 Fastify 快 6 倍,性能堪比 Go 和 Rust 框架!

  • • 🚀 开发体验好:链式 API 设计,代码更简洁优雅

  • • 📦 类型安全:端到端类型安全,自动类型推断

  • • ⚡ 跨运行时:同时支持 Bun 和 Node.js

Elysia 也支持 Node.js!

虽然 Elysia 在 Bun 上性能最佳,但它完全兼容 Node.js

# Node.js 用户安装
npm install elysia
npm install @elysiajs/node  # Node 适配器
// Node.js 版本入口文件
import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

const app = new Elysia()
  .get("/", () => ({ message: "Hello from Node.js!" }))
  .listen(3000, node);  // 使用 node 适配器

本课程使用 Bun 运行,因为更轻量、启动更快。但你可以放心,学到的 Elysia 知识在 Node.js 中完全适用!

你将获得:

  • • ✅ 一个能运行的 HTTP 服务器

  • • ✅ 两个 API 接口

  • • ✅ 请求日志记录

全程不写代码,只写提示词。


第0步:了解 Trae 和 AGENTS.md(推荐)

什么是 Trae?

Trae 是字节跳动推出的 AI 原生 IDE,类似于 Cursor,专为 AI 编程而生:

  • • 🤖 内置 AI 助手:无需配置 API Key,开箱即用

  • • 💬 自然语言编程:用中文描述需求,AI 直接生成代码

  • • 🔧 代码自动补全:比 GitHub Copilot 更智能的上下文理解

  • • 🌟 免费使用:目前完全免费,无额度限制

本课程所有代码都通过 Trae 的 Auto 模型 生成,你只需要复制提示词,Trae 就能帮你写出符合规范的后端代码。

什么是 AGENTS.md?

在开始前,介绍一个提升 AI 生成代码质量的神器 —— AGENTS.md

什么是 AGENTS.md?

AGENTS.md 是放在项目根目录的配置文件,类似于 Cursor 的 .cursorrules。它告诉 AI:

  • • 项目使用什么技术栈

  • • 代码应该遵循什么规范

  • • 文件应该如何组织

为什么用它?

不用 AGENTS.md:

  • • 每次都要在提示词里重复技术栈

  • • AI 生成的代码风格不统一

  • • 需要反复修改才能符合项目规范

使用 AGENTS.md:

  • • 一次定义,永久生效

  • • AI 自动遵循项目规范

  • • 生成的代码直接可用

创建 AGENTS.md

在项目根目录创建 AGENTS.md 文件:

# 项目规范

## 技术栈
- Bun.js + TypeScript
- Elysia 框架(高性能 Web 框架)
- SQLite 数据库(后续课程)

## 代码规范
- 使用 TypeScript 严格模式
- 统一返回格式 { success, data, message }
- 路由使用 RESTful 规范
- 所有 SQL 使用 :name 占位符(防注入)

## 文件结构
- index.ts - 主入口
- db.ts - 数据库连接(后续添加)
- routes/ - 路由定义(后续添加)

在 Trae 中使用

现在,让我们开始创建第一个后端服务!


第1步:安装运行环境(1分钟)

💡 提示:不想安装 Bun?用 Node.js 也可以!

如果你电脑上已经有 Node.js,可以直接跳到第2步,用 npm 代替 bun 安装依赖。

方案 A:安装 Bun(推荐,更轻量更快)

Bun 是什么?

一个超快的 JavaScript 运行时,开箱即用,无需配置。

🚀 为什么推荐 Bun?未来趋势洞察

2025年,Bun 被 Claude 母公司 Anthropic 收购。这意味着什么?

  • • AI 原生支持:未来 AI 自动建站工具很可能默认基于 Bun 运行时

  • • 全栈一体化:Bun = Node.js + Webpack + Jest + npm

  • • TSX 原生支持:直接运行 TypeScript + JSX

  • • 边缘计算:比 Node.js 更轻量,适合 IoT 和边缘部署

💡 预测:未来你只需要输入"帮我做一个电商网站",AI 就能直接生成基于 Bun 的完整全栈应用。

🔥 Bun 真的太强了! 启动速度快 4 倍,内存占用少一半。

包管理速度对比:

| 工具 | 安装时间 | 相对速度 | | :-- | :-- | :-- | | Bun | ~391ms | 基准 ⚡ | | pnpm | ~1,023ms | 2.6x 慢 | | Yarn | ~3,206ms | 8.2x 慢 | | npm | ~4,503ms | 11.5x 慢 |

💡 bun install 比 npm install 快 11 倍

强烈建议:尽量用 Bun!这是面向未来的投资

安装命令:

# macOS / Linux
curl -fsSL https://bun.sh/install | bash

# Windows
powershell -c "irm bun.sh/install.ps1|iex"

📦 命令行下载失败? 可以使用网盘下载:123云盘1859113306.share.123865.com/123pan/na8c…

下载后解压,将 bun.exe 放到系统 PATH 目录即可使用。

Windows 具体操作步骤:

  1. 1. 解压下载的文件,找到 bun.exe

  2. 2. 将 bun.exe 复制到 C:\Windows\System32 目录(需要管理员权限)

  3. 3. 或者创建一个目录如 C:\bun,将 bun.exe 放进去,然后添加到 PATH:

  • • 右键"此电脑" → 属性 → 高级系统设置 → 环境变量

  • • 在"系统变量"中找到 Path,双击编辑

  • • 点击"新建",输入 C:\bun,确定保存

  1. 4. 打开新的命令提示符窗口,输入 bun --version 验证

验证安装:

bun --version
# 输出类似:1.1.0

方案 B:使用 Node.js(如果你已有 Node.js)

如果你不想安装 Bun,Node.js 完全没问题

检查 Node.js 版本:

node --version
# 建议 v18 以上

后续步骤中,把 bun 换成 npm 或 npx 即可:

  • • bun add elysia → npm install elysia

  • • bun run index.ts → npx ts-node index.ts(需先安装 ts-node

✅ 搞定!环境准备完成。


第2步:创建项目(30秒)

Bun 用户

mkdir my-first-backend
cd my-first-backend
bun init -y

安装 Elysia:

bun add elysia

Node.js 用户

mkdir my-first-backend
cd my-first-backend
npm init -y
npm install typescript @types/node ts-node --save-dev
npx tsc --init

安装 Elysia 和 Node 适配器:

npm install elysia @elysiajs/node

修改 package.json 添加启动脚本:

{
  "scripts": {
    "dev""ts-node index.ts"
  }
}

这会生成一个基本的项目结构:

my-first-backend/
├── package.json
├── tsconfig.json
└── index.ts

(可选)创建 AGENTS.md:

echo "# 项目规范

## 技术栈
- Bun.js + TypeScript
- Elysia 框架

## 代码规范
- 统一返回格式 { success, data, message }
- RESTful API 设计" > AGENTS.md

第3步:复制提示词给 AI(1分钟)

在 Trae 中,使用 Auto 模型,复制这段提示词:

用 Elysia 框架创建一个 HTTP 服务器

要求:
1. 监听端口 3000
2. 实现两个路由:
   - GET / 返回 { message: "Hello World", time: 当前时间 }
   - GET /time 返回 { timestamp: 时间戳, iso: ISO格式时间 }
3. 添加请求日志:记录每个请求的方法和路径
4. 启动时打印:Server running at http://localhost:3000

请生成完整的 TypeScript 代码,保存到 index.ts

Trae 会生成类似这样的代码:

import { Elysia } from "elysia";

const app = new Elysia()
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })
  .get("/", () => ({
    message: "Hello World",
    time: new Date().toLocaleString()
  }))
  .get("/time", () => {
    const now = new Date();
    return {
      timestamp: now.getTime(),
      iso: now.toISOString()
    };
  })
  .listen(3000);

console.log(`Server running at http://localhost:${app.server?.port}`);

把代码复制到 index.ts 文件中。

💡 Node.js 用户注意:需要修改代码使用 Node 适配器

import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

const app = new Elysia()
  .get("/", () => ({ message: "Hello World" }))
  .listen(3000, node);  // ← 加上 node 适配器

第4步:运行!(30秒)

Bun 用户

bun run index.ts

Node.js 用户

npm run dev

看到输出:

Server running at http://localhost:3000

✅ 恭喜你,后端服务跑起来了!


第5步:测试接口(2分钟)

打开浏览器,访问:

http://localhost:3000

看到返回:

{
  "message""Hello World",
  "time""2024/1/15 10:30:00"
}

再访问:

http://localhost:3000/time

看到返回:

{
  "timestamp": 1705312200000,
  "iso""2024-01-15T02:30:00.000Z"
}

查看终端日志:

GET /
GET /time

✅ 完美!你的第一个后端服务正常运行。


代码解析

让我们看看 AI 生成的代码,理解它在做什么:

import { Elysia } from "elysia";

const app = new Elysia()           // 创建 Elysia 应用实例
  .onRequest(({ request }) => {    // 注册请求拦截器(日志)
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })
  .get("/", () => ({               // 定义 GET / 路由
    message: "Hello World",
    time: new Date().toLocaleString()
  }))
  .get("/time", () => {             // 定义 GET /time 路由
    const now = new Date();
    return {
      timestamp: now.getTime(),
      iso: now.toISOString()
    };
  })
  .listen(3000);                    // 监听 3000 端口

对应我们上节课学的 5 步法:

| 步骤 | 代码体现 | | :-- | :-- | | 接收请求 | new Elysia()  创建应用,.listen() 监听端口 | | 解析验证 | .get()  自动匹配路由和方法 | | 业务处理 | 路由回调函数中返回不同的数据(时间、消息等) | | 操作数据 | 本例无数据库,直接返回当前时间 | | 返回响应 | 直接返回对象,Elysia 自动序列化为 JSON |

Elysia vs 原生 Bun.serve

| 特性 | 原生 Bun.serve | Elysia 框架 | | :-- | :-- | :-- | | 代码量 | 较多,需手动处理路由 | 简洁,链式 API | | 路由管理 | 手动 if/else 判断 | 声明式 .get()``.post() | | 中间件 | 需自行实现 | 内置 .onRequest() | | 类型安全 | 无 | 端到端类型推断 | | 性能 | 快 | 更快(优化过的路由匹配) |

常用中间件示例

Elysia 有丰富的官方插件生态,常用中间件使用示例:

import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";        // 跨域
import { swagger } from "@elysiajs/swagger";  // API 文档
import { jwt } from "@elysiajs/jwt";          // JWT 认证
import { staticPlugin } from "@elysiajs/static"; // 静态文件

const app = new Elysia()
  // 跨域支持
  .use(cors({
    origin: "*",  // 允许所有域名,生产环境建议指定具体域名
    methods: ["GET""POST""PUT""DELETE"]
  }))
  
  // Swagger API 文档(访问 /swagger)
  .use(swagger({
    documentation: {
      info: {
        title: "我的 API",
        version: "1.0.0"
      }
    }
  }))
  
  // JWT 认证
  .use(jwt({
    secret: "your-secret-key",
    exp: "7d"  // 7天过期
  }))
  
  // 静态文件服务(public 目录)
  .use(staticPlugin({
    prefix: "/static",  // 访问路径前缀
    assets: "public"    // 本地目录
  }))
  
  // 自定义中间件:统一响应格式
  .onAfterHandle(({ response, set }) => {
    // 如果已经是标准格式,直接返回
    if (response && typeof response === "object" && "success" in response) {
      return response;
    }
    // 包装成标准格式
    return {
      success: true,
      data: response,
      message: "操作成功"
    };
  })
  
  // 错误处理中间件
  .onError(({ code, error, set }) => {
    console.error(`错误 [${code}]:`, error);
    set.status = code === "NOT_FOUND" ? 404 : 500;
    return {
      success: false,
      data: null,
      message: error.message || "服务器内部错误"
    };
  })

  .get("/", () => "Hello World")
  .listen(3000);

安装这些插件:

# Bun
bun add @elysiajs/cors @elysiajs/swagger @elysiajs/jwt @elysiajs/static

# Node.js
npm install @elysiajs/cors @elysiajs/swagger @elysiajs/jwt @elysiajs/static

如果出错了怎么办?

问题1:端口被占用

错误信息:

error: Failed to start server. Is port 3000 in use?

解决: 换一个端口

.listen(3001)  // 改成 3001

问题2:模块找不到

错误信息:

error: Cannot find module 'elysia'

解决: 安装依赖

bun add elysia

问题3:代码报错

把错误信息复制给 Trae:

我的代码报错了:
[粘贴错误信息]

请帮我修复。

问题4:浏览器访问没反应

检查:


核心收获

今天你用 5 分钟完成了:

✅ 安装 Bun 环境✅ 创建后端项目✅ 了解 AGENTS.md 的作用✅ 用 Trae 生成 Elysia 代码✅ 运行并测试服务

全程只写了 1 段提示词,0 行代码。


下节课预告

第3课:路由是什么?怎么设计好懂的 API?

我们将:

  • • 理解路由的概念

  • • 学习 RESTful API 设计

  • • 用 AI 生成一个用户管理 API


思考题:

试着修改提示词,让 AI 添加一个新接口 /hello?name=张三,返回 { message: "你好,张三" }

欢迎在评论区分享你的提示词。


如果觉得有帮助,欢迎点赞、在看、转发。

别把语音 Agent 当成“接两个 API”——用 NestJS 搭一套 ASR + LLM + 流式 TTS 的实时语音助手

作者 swipe
2026年5月5日 16:17

我们现在看到的大多数 AI 助手,已经默认具备语音能力:你说一句话,它先把语音转成文字;大模型理解问题后,边生成文字边输出答案;最后,再把这段答案用自然语音朗读出来。

从表面看,这件事像是把三个能力串起来:

  • ASR(Automatic Speech Recognition,语音识别)
  • LLM(大模型推理)
  • TTS(Text To Speech,语音合成)

但真正做过这类系统,你会发现问题根本不在“有没有接上接口”,而在链路能不能协同工作

很多 Demo 的问题不是不能跑,而是体验不对:

  • 录音能识别,但只能整段上传,交互很生硬
  • 大模型能流式返回,但语音要等整段文本结束后才开始播放
  • 前端能显示文字,但音频播放一顿一顿
  • 文本和语音各走各的,最后很容易出现“字已经出完了,音频还没开始”
  • 一旦中间某条连接断掉,整条语音链路就会失去同步

所以,这篇文章我不打算把它写成“如何分别调用腾讯云 ASR、腾讯云 TTS 和大模型 API”的资料拼盘。我想讲清楚一个更关键的结论:

语音版 AI 助手真正的难点,不是单独把 ASR、LLM、TTS 跑通,而是把“上传式语音识别、SSE 文本流、WebSocket 二进制音频流、服务端事件桥接、前端流式播放”组织成一条低耦合、可持续输出的实时链路。

本文基于一个真实可运行的 NestJS 项目来展开,项目里已经具备这几部分能力:

  • 浏览器录音并上传到 /speech/asr
  • 服务端调用腾讯云 ASR 做语音转文字
  • 文本问题进入 /ai/chat/stream,以 SSE 形式流式输出
  • 服务端将大模型输出通过事件桥接给流式 TTS
  • 腾讯云流式 TTS 返回二进制音频,通过 WebSocket 推给前端
  • 前端用 MediaSource + SourceBuffer 做边收边播

这套设计不一定是生产级语音系统的终点,但非常适合作为一个工程上讲得通、链路上闭得上、博客里讲得清的默认方案。


一、先别急着写代码:语音 AI 助手其实是三条链路

如果你一开始就把语音助手理解成“录音之后调一次接口”,你大概率会把结构做歪。

从工程上看,至少要先拆出三条职责不同的链路:

  1. 输入链路:浏览器录音 -> 上传音频 -> ASR -> 文本
  2. 推理链路:文本问题 -> LLM -> 流式文本输出
  3. 播报链路:流式文本 -> TTS -> 二进制音频 -> 边收边播

这三条链路的通信方式、时序要求、数据形态都不一样:

  • 录音上传是文件型请求,适合 multipart/form-data
  • 大模型文本输出是连续文本流,适合 SSE
  • 音频是二进制数据流,更适合 WebSocket

也就是说,这不是“一个接口做三件事”的问题,而是“多条流如何协作”的问题。

如果把这三段混成一个大接口,通常会出现两个后果:

  • 业务代码耦合严重,后面任何一段升级都很痛苦
  • 文本和音频时序失控,体验会非常差

所以我建议你先把这件事理解成一个多流协同系统,再去看具体实现。


二、这套项目的核心架构是什么

先看整条链路的全貌。本文分析的项目里,NestJS 并不是简单的 API 网关,而是把三种协议和两种外部能力组织起来的中枢。

flowchart LR
    subgraph Browser[浏览器端]
        A[录音采集<br/>MediaRecorder]
        B[上传音频<br/>POST /speech/asr]
        O[文字逐字显示]
        M[语音通道<br/>GET /speech/tts/ws]
        N[流式播放<br/>MediaSource + SourceBuffer]
        P[边收边播]
    end

    subgraph Server[NestJS 服务端]
        C[SpeechController / SpeechService]
        F[AiController<br/>SSE /ai/chat/stream]
        G[AiService + LangChain]
        I[事件桥接<br/>AI_TTS_STREAM_EVENT]
        J[TtsRelayService]
    end

    subgraph Cloud[外部云服务]
        D[腾讯云 ASR]
        K[腾讯云流式 TTS WebSocket]
    end

    A --> B
    B --> C
    C --> D
    D --> E[识别文本]
    E --> F
    F --> G
    G --> H[大模型流式文本]
    H --> O
    H --> I
    I --> J
    J --> K
    K --> L[二进制 MP3 音频帧]
    L --> M
    M --> N
    N --> P

这张图里最值得注意的,不是腾讯云,也不是模型,而是中间这几个“看起来不起眼”的节点:

  • SSE
  • WebSocket
  • AI_TTS_STREAM_EVENT
  • MediaSource
  • SourceBuffer

这几个点决定了语音链路到底是“实时协同”,还是“能跑但体验别扭”。

再看一次时序,你会更直观一些:

sequenceDiagram
    participant U as 用户
    participant FE as 浏览器前端
    participant ASR as NestJS /speech/asr
    participant TCASR as 腾讯云 ASR
    participant AI as NestJS /ai/chat/stream
    participant LLM as 大模型
    participant RELAY as TtsRelayService
    participant TCTTS as 腾讯云流式TTS

    U->>FE: 录音并停止
    FE->>ASR: 上传音频文件
    ASR->>TCASR: SentenceRecognition
    TCASR-->>ASR: 返回识别文本
    ASR-->>FE: text
    FE->>RELAY: 建立 /speech/tts/ws
    FE->>AI: 发起 /ai/chat/stream?ttsSessionId=xxx
    AI->>LLM: 流式生成回答
    LLM-->>AI: 文本 chunk
    AI-->>FE: SSE 文本 chunk
    AI->>RELAY: 发出 chunk 事件
    RELAY->>TCTTS: ACTION_SYNTHESIS 分段文本
    TCTTS-->>RELAY: 二进制音频帧
    RELAY-->>FE: WebSocket 音频数据
    FE->>FE: SourceBuffer appendBuffer
    FE-->>U: 边显示文字边播放语音

理解了这张图,后面的代码就不再是“API 堆砌”,而是各自承担某个链路角色。


三、先看项目结构:这个仓库为什么这么拆

这个项目的 README 仍然是 NestJS 默认模板,真正的信息都在源码里。核心结构大致如下:

src/
  ai/
    ai.config.ts
    ai.controller.ts
    ai.module.ts
    ai.service.ts
  speech/
    speech.config.ts
    speech.controller.ts
    speech.module.ts
    speech.service.ts
    tts-relay.service.ts
    tts-text-segmentation.ts
  common/
    stream-events.ts
  main.ts
public/
  asr.html
  asr-stream.html

这套拆分是合理的:

  • ai/:只关心文本问答链路
  • speech/:只关心语音输入、语音输出以及第三方语音服务接入
  • common/stream-events.ts:作为事件约定,让 AI 和 TTS 解耦
  • public/asr.html:单独验证 ASR 上传链路
  • public/asr-stream.html:完整验证“录音 -> 识别 -> AI -> TTS”链路

四、为什么 ASR 这里我更推荐“录完再识别”而不是一上来就做流式识别

很多人一聊语音系统,就默认“必须实时流式 ASR”。这其实是个常见误区。

要不要流式识别,不应该由技术潮流决定,而应该由交互目标决定。

在这个项目里,语音输入的目标不是做电话机器人,也不是做毫秒级打断对话,而是做一个像豆包那样的单轮语音提问

  1. 用户说完一段话
  2. 系统识别成文本
  3. 大模型开始回答
  4. 回答边生成边播报

在这种场景里,先录完再识别,其实是非常合理的默认方案:

  • 实现复杂度明显更低
  • 浏览器端更容易兼容
  • 后端不需要先处理麦克风实时分片上送
  • 对“问一句 -> 回一句”的交互已经够用

换句话说,流式 ASR 不是默认最优,而是更高阶、更高成本的能力。

如果你的目标只是做一个能交互、可演示、可扩展的语音 AI 助手,把复杂度优先放在“输出链路流式化”上,通常是更划算的。


五、ASR 后端是怎么接的:/speech/asr 这条链路的职责非常清晰

先看控制器:

// src/speech/speech.controller.ts
@Controller('speech')
export class SpeechController {
  constructor(private readonly speechService: SpeechService) {}

  @Post('asr')
  @UseInterceptors(FileInterceptor('audio'))
  async recognize(
    @UploadedFile()
    file?: {
      buffer: Buffer;
      originalname: string;
      mimetype: string;
      size: number;
    },
  ) {
    if (!file?.buffer?.length) {
      throw new BadRequestException(
        '请通过 FormData 的 audio 字段上传音频文件',
      );
    }

    const text = await this.speechService.recognizeBySentence(file);
    return { text };
  }
}

这个接口做得很干净:

  • 它不负责录音
  • 不负责转码
  • 不负责前端 UI
  • 只负责接收 audio 文件并转交给 SpeechService

这就是好接口的样子:边界清楚,职责单一。

再看识别逻辑:

// src/speech/speech.service.ts
@Injectable()
export class SpeechService {
  constructor(@Inject('ASR_CLIENT') private readonly asrClient: AsrClient) {}

  async recognizeBySentence(file: UploadedAudio): Promise<string> {
    const audioBase64 = file.buffer.toString('base64');

    const result = await this.asrClient.SentenceRecognition({
      EngSerViceType: '16k_zh',
      SourceType: 1,
      Data: audioBase64,
      DataLen: file.buffer.length,
      VoiceFormat: 'ogg-opus',
    });

    return result.Result ?? '';
  }
}

这里有几个参数值得讲透,而不是只说“它们怎么填”:

1)为什么要 buffer -> base64

因为这里调用的是云厂商 SDK 的句子识别接口,音频数据需要以指定格式进入请求体。浏览器上传到服务端后,Nest 拿到的是内存中的二进制 Buffer,而腾讯云接口在这个模式下要吃的是 Base64 文本。

也就是说,这一步不是“多余的转换”,而是协议适配。

2)EngSerViceType: '16k_zh' 在表达什么

这个参数不是“中文模式”这么简单,它实际上约束了:

  • 识别语种
  • 采样率预期
  • 模型适配方向

如果你音频本身和服务类型不匹配,识别结果就容易变差,甚至直接报错。很多人觉得“ASR 效果不好”,其实不是模型差,而是输入格式、采样率、编码方案就没对齐。

3)为什么 VoiceFormat 这里用 ogg-opus

因为前端 MediaRecorder 优先录的是:

const preferredMimeType = "audio/ogg;codecs=opus";

前后端格式是对齐的。这个细节非常关键。

如果你前端录出来的是 webm/opus,服务端却告诉云厂商它是 ogg-opus,结果要么识别失败,要么内容异常。音频链路里,格式匹配远比“我觉得差不多”更重要。


六、前端录音链路的设计,为什么比“点一下录音按钮”复杂

public/asr.html 里,项目专门做了一个 ASR 验证页。这个页面的意义并不只是演示,而是把“录音 -> 上传 -> 识别”单独拆出来验证。

核心代码是这段:

mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];

const mimeType = MediaRecorder.isTypeSupported(preferredMimeType)
  ? preferredMimeType
  : fallbackMimeType;

mediaRecorder = new MediaRecorder(mediaStream, { mimeType });

mediaRecorder.ondataavailable = (event) => {
  if (event.data && event.data.size > 0) {
    chunks.push(event.data);
  }
};

mediaRecorder.onstop = async () => {
  const blob = new Blob(chunks, {
    type: mediaRecorder.mimeType || fallbackMimeType,
  });
  const data = await uploadRecording(blob);
  resultEl.textContent = data.text || '(空结果)';
};

mediaRecorder.start(250);

这段代码在整条链路里的位置,是语音输入采集层。它解决的不是识别,而是三个更基础的问题:

  1. 如何向浏览器申请麦克风权限
  2. 如何把录音分片收集起来
  3. 如何在停止录音后整合成可上传的 Blob

注意这里 start(250) 的意义:虽然当前链路是“录完再识别”,但录音过程中仍然是按 250ms 分片收集的。这么做的好处是:

  • 浏览器侧更平滑
  • 后续如果要升级成更实时的链路,基础采集方式不用推翻
  • 可以更容易做波形、计时、录音中状态等 UI

也就是说,这个前端实现虽然现在走的是上传式 ASR,但它没有把自己写死在“纯离线式”的思路里。


七、为什么大模型输出要走 SSE,而不是普通 HTTP 返回一整段文本

语音助手如果只返回整段文本,问题不止是“慢”,而是整个系统没法形成实时反馈。

用户体验上的关键差别在这里:

  • 普通 HTTP:用户必须等待全部生成完成
  • SSE:前端可以随着 chunk 逐步展示答案

在语音场景里,这个差异会进一步放大。因为 TTS 的输入来源就是大模型流式文本。如果文本不流,语音就没法流。

也就是说,TTS 能不能边播,根上取决于 LLM 文本能不能边出。

项目里的 SSE 接口非常简洁:

// src/ai/ai.controller.ts
@Controller('ai')
export class AiController {
  constructor(
    private readonly aiService: AiService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  @Sse('chat/stream')
  chatStream(
    @Query('query') query: string,
    @Query('ttsSessionId') ttsSessionId?: string,
  ): Observable<{ data: string }> {
    const sessionId = ttsSessionId?.trim();
    if (sessionId) {
      const startEvent: AiTtsStreamEvent = { type: 'start', sessionId, query };
      this.eventEmitter.emit(AI_TTS_STREAM_EVENT, startEvent);
    }

    return from(this.aiService.streamChain(query, sessionId)).pipe(
      map((chunk) => ({ data: chunk })),
    );
  }
}

这里有两个关键点:

第一,接口同时服务了两种消费方

  • 前端通过 SSE 消费文本
  • TTS 服务通过事件总线消费同一份文本流

这意味着这不是一个“只给前端看的接口”,而是整个问答输出链路的上游。

第二,ttsSessionId 把文本流和音频流关联起来了

为什么这里要额外传 ttsSessionId

因为前端和后端之间其实维护着两条通道:

  • 一条是 EventSource 文本流
  • 一条是 WebSocket 音频流

如果没有一个会话 ID 把二者绑定起来,你根本没法知道“这一段文本应该送到哪条 TTS WebSocket 会话里去”。

这就是语音系统和普通文本聊天系统的本质差别之一:你必须处理跨协议、跨通道的会话一致性。


八、AI 模块看起来简单,但其实承担的是“文本流标准化”的职责

再看 AiService

// src/ai/ai.service.ts
@Injectable()
export class AiService {
  private readonly chain: Runnable;

  constructor(
    @Inject('CHAT_MODEL') model: ChatOpenAI,
    private readonly eventEmitter: EventEmitter2,
  ) {
    const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
    this.chain = prompt.pipe(model).pipe(new StringOutputParser());
  }

  async *streamChain(
    query: string,
    ttsSessionId?: string,
  ): AsyncGenerator<string> {
    try {
      const stream = (await this.chain.stream({ query })) as AsyncIterable<unknown>;
      for await (const rawChunk of stream) {
        let chunk = '';
        if (typeof rawChunk === 'string') {
          chunk = rawChunk;
        } else if (
          typeof rawChunk === 'number' ||
          typeof rawChunk === 'boolean' ||
          typeof rawChunk === 'bigint'
        ) {
          chunk = String(rawChunk);
        }
        if (!chunk) continue;

        if (ttsSessionId) {
          this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
            type: 'chunk',
            sessionId: ttsSessionId,
            chunk,
          });
        }
        yield chunk;
      }

      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'end',
          sessionId: ttsSessionId,
        });
      }
    } catch (error) {
      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'error',
          sessionId: ttsSessionId,
          error: error instanceof Error ? error.message : String(error),
        });
      }
      throw error;
    }
  }
}

很多文章写到这里,就会开始说“看,这里用了 LangChain”。但真正有价值的,不是它用了哪个框架,而是这个 Service 做了什么抽象。

我认为这段代码承担的是三层职责:

1)把模型输出统一成字符串流

模型底层返回的 chunk 未必永远是字符串,代码里显式对 number / boolean / bigint 做了兼容转换。这是很实在的工程写法:不要假设上游永远完美,尽量把消费层看到的输出标准化。

2)把文本流同时暴露给两类消费者

  • yield chunk 给 SSE
  • eventEmitter.emit(...) 给 TTS 侧

注意,这里不是“生成两次”,而是一份文本流,多方消费。这在实时系统里非常重要,否则你很容易出现“前端看到的内容”和“语音朗读的内容”不一致。

3)在流的生命周期上补全事件

除了 chunk 之外,它还显式发出了:

  • start
  • end
  • error

这意味着 TTS 侧不只是“收到一点字就说一点字”,而是知道:

  • 什么时候会话开始
  • 什么时候应该收尾
  • 什么时候要终止并清理资源

这就是完整流生命周期管理,而不是简单回调。


九、为什么流式 TTS 不能和 SSE 混在一起

很多第一次做这个系统的人会问:既然已经有 SSE 了,为什么不直接在 SSE 里把音频也发回来?

原因很简单:SSE 适合文本,不适合二进制音频。

如果你强行用 SSE 传音频,一般只有两条路:

  1. 把音频转 Base64 再发
  2. 伪装成文本分块传输

这两种路都不太好:

  • Base64 体积会膨胀
  • 前端要自己解码
  • 时序会更难控制
  • 对播放器非常不友好

而 WebSocket 天然适合持续传二进制帧,所以这里单独开一条 /speech/tts/ws 通道,是一个非常明确的工程判断:

文本输出归 SSE,音频输出归 WebSocket。

这不是“多开一个接口显得复杂”,而是“按数据类型选协议”。


十、真正决定这套系统可扩展性的,是事件桥接而不是 API 调用

这套项目里,我最认可的一点是没有把 TTS 逻辑硬塞进 AiControllerAiService 里,而是通过事件来桥接。

事件定义很简单:

// src/common/stream-events.ts
export const AI_TTS_STREAM_EVENT = 'ai.tts.stream';

export type AiTtsStreamEvent =
  | { type: 'start'; sessionId: string; query: string }
  | { type: 'chunk'; sessionId: string; chunk: string }
  | { type: 'end'; sessionId: string }
  | { type: 'error'; sessionId: string; error: string };

这段代码看似不复杂,但它非常值钱。因为它明确告诉你:AI 模块不关心 TTS 怎么连腾讯云、怎么推前端、怎么关连接,它只负责把“文本流生命周期”以事件方式发出去。

这带来两个工程收益:

1)低耦合

未来你要把腾讯云 TTS 换成别家,实现新的 listener 就行,AI 侧不用改。

2)可演进

今天事件被 TtsRelayService 消费,明天也可以被日志系统、审计系统、字幕系统、消息持久化系统消费。

这就是为什么我说,语音系统的核心不在“调 API”,而在“如何组织流”。


十一、TtsRelayService 才是这套方案最核心的后端实现

如果让我选一个最值得反复讲的文件,那一定是:

  • src/speech/tts-relay.service.ts

它的职责不是“做 TTS”这么简单,而是做了三层中继:

  1. 管理浏览器和服务端之间的 TTS 会话
  2. 管理服务端和腾讯云流式 TTS 之间的 WebSocket 连接
  3. 在文本分段、发送节奏、二进制转发之间做协调

先看客户端会话注册:

registerClient(clientWs: WebSocket, wantedSessionId?: string): string {
  const sessionId = wantedSessionId?.trim() || randomUUID();
  const existing = this.sessions.get(sessionId);
  if (existing) {
    this.closeSession(sessionId, 'client reconnected');
  }

  this.sessions.set(sessionId, {
    sessionId,
    clientWs,
    ready: false,
    pendingChunks: [],
    textBuffer: '',
    closed: false,
  });
  this.sendClientJson(clientWs, { type: 'session', sessionId });
  return sessionId;
}

这里不是简单“建立一个 ws 就完事”,而是创建了一个完整的 session 对象。里面几个字段都非常有用:

  • ready:腾讯云流式 TTS 是否已经可以收文本
  • pendingChunks:还没来得及送出去的待合成文本
  • textBuffer:当前累计但尚未完成分段的文本
  • closed:避免已关闭 session 继续写数据

这说明作者不是把 WebSocket 当成“收发消息”的黑盒,而是把它当成一个有状态流会话来管理。

再看事件消费:

@OnEvent(AI_TTS_STREAM_EVENT)
handleAiStreamEvent(event: AiTtsStreamEvent): void {
  const session = this.sessions.get(event.sessionId);
  if (!session) return;

  switch (event.type) {
    case 'start': {
      this.ensureTencentConnection(session);
      this.sendClientJson(session.clientWs, {
        type: 'tts_started',
        sessionId: session.sessionId,
        query: event.query,
      });
      break;
    }
    case 'chunk': {
      const chunk = event.chunk;
      if (!chunk) return;
      this.queueSpeakableSegments(session, chunk);
      break;
    }
    case 'end': {
      this.queueSpeakableSegments(session, '', true);
      this.flushPendingChunks(session);
      if (session.tencentWs && session.tencentWs.readyState === WebSocket.OPEN) {
        session.tencentWs.send(JSON.stringify({
          session_id: session.sessionId,
          action: 'ACTION_COMPLETE',
        }));
      }
      break;
    }
    case 'error': {
      this.sendClientJson(session.clientWs, {
        type: 'tts_error',
        message: event.error,
      });
      this.closeSession(session.sessionId, 'ai stream error');
      break;
    }
  }
}

这段逻辑的价值在于:它把 TTS 的行为建立在流生命周期事件之上,而不是建立在“文本一下子全来了”之上。

尤其是 end 分支非常重要:

  • 先强制把剩余文本分段刷出去
  • 再把待发送队列 flush
  • 最后给腾讯云发 ACTION_COMPLETE

这表示“输入流结束”的协议语义被显式处理了。如果少了这一步,常见后果就是最后一段语音永远不出。


十二、流式 TTS 最大的坑,不是连接,而是文本分段

很多人第一次做流式 TTS,会把模型每次吐出的 chunk 原封不动地送去合成。结果通常很糟糕:

  • 语音频繁断句
  • 一两个字就触发一次合成
  • 朗读节奏极不自然
  • 网络与云服务调用次数暴涨

所以,这个项目专门做了一个分段器:src/speech/tts-text-segmentation.ts

核心逻辑是:

const SENTENCE_END_RE = /[。!?!?;;\n]/;
const FORCE_SPLIT_RE = /[,,、::\s]/g;
const MIN_FORCE_SPLIT_LENGTH = 18;
const MAX_BUFFER_LENGTH = 48;

export function extractTtsSegments(
  input: string,
  forceFlush = false,
): { segments: string[]; rest: string } {
  let rest = input;
  const segments: string[] = [];

  while (rest) {
    const sentenceMatch = rest.match(SENTENCE_END_RE);
    if (sentenceMatch?.index !== undefined) {
      const endIndex = sentenceMatch.index + sentenceMatch[0].length;
      const segment = finalizeSegment(rest.slice(0, endIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(endIndex).trimStart();
      continue;
    }

    const forcedSplitIndex = findForcedSplitIndex(rest);
    if (forcedSplitIndex > 0) {
      const segment = finalizeSegment(rest.slice(0, forcedSplitIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(forcedSplitIndex).trimStart();
      continue;
    }

    break;
  }

  if (forceFlush) {
    const finalSegment = finalizeSegment(rest);
    if (finalSegment) segments.push(finalSegment);
    rest = '';
  }

  return { segments, rest };
}

这里体现了一个很重要的工程判断:

流式 TTS 追求的不是“字一出来马上说”,而是“在足够低延迟的前提下,让语音仍然像人在说话”。

这段策略基本分三层:

1)优先按句末标点切分

。!?; 这种天然句边界,最适合合成。因为朗读的停顿也会更自然。

2)句末标点迟迟不来时,允许在逗号、顿号、空格附近强制切分

这是为了控制首包延迟。否则模型一直生成长句,但迟迟不出句号,用户就会觉得“怎么还不说话”。

3)最后在流结束时强制 flush

如果最后一段没等到标点,也不能丢,必须在 forceFlush 时兜底发出去。

很多系统之所以语音体验差,不是模型不行,而是文本分段策略太粗糙


十三、腾讯云流式 TTS 的真正接入点,不是 SDK,而是 WebSocket 协议管理

TTS 中继服务还有一个核心职责:维护服务端到腾讯云的流式 WebSocket 连接。

例如这个签名 URL 构建:

private buildTencentTtsWsUrl(sessionId: string): string {
  const now = Math.floor(Date.now() / 1000);
  const params: Record<string, string | number> = {
    Action: 'TextToStreamAudioWSv2',
    AppId: this.appId,
    Codec: 'mp3',
    Expired: now + 3600,
    SampleRate: 16000,
    SecretId: this.secretId,
    SessionId: sessionId,
    Speed: 0,
    Timestamp: now,
    VoiceType: this.voiceType,
    Volume: 5,
  };

  const signStr = Object.keys(params)
    .sort()
    .map((k) => `${k}=${params[k]}`)
    .join('&');
  const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
  const signature = createHmac('sha1', this.secretKey)
    .update(rawStr)
    .digest('base64');

  return `wss://tts.cloud.tencent.com/stream_wsv2?...`;
}

这段代码告诉你两件事:

  1. 流式 TTS 本质上是一个 WebSocket 协议接入问题
  2. 真正的复杂度不在“调某个函数”,而在“参数、签名、时序、收尾”这些协议细节

再比如文本发送逻辑:

private sendTencentChunk(session: ClientSession, text: string): void {
  if (!session.tencentWs || session.tencentWs.readyState !== WebSocket.OPEN) {
    session.pendingChunks.push(text);
    return;
  }

  session.tencentWs.send(
    JSON.stringify({
      session_id: session.sessionId,
      message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      action: 'ACTION_SYNTHESIS',
      data: text,
    }),
  );
}

为什么这里不是“收到文本就立刻发”?因为发送之前必须确认:

  • 连接是否建立
  • 会话是否 ready
  • 上游是否还有待合成文本未处理

这就是为什么 pendingChunks 队列存在。它解决的是流的生产速度和消费速度不一致的问题。


十四、前端为什么要用 MediaSource + SourceBuffer,而不是直接拿到 MP3 再播

如果你只是做“整段 TTS 合成后再播放”,那确实可以直接把一个完整音频文件地址塞进 <audio>

但这个项目要解决的是:后端持续推音频二进制,前端边收到边播放。

这时,普通 audio 标签就不够了,你需要一个可以持续追加媒体数据的机制。这就是 MediaSource

看前端核心代码:

function prepareStreamingAudio () {
  if (ttsMediaSource && ttsSourceBuffer) return true;
  if (!window.MediaSource || !MediaSource.isTypeSupported("audio/mpeg")) {
    return false;
  }

  resetTtsPlayer();
  ttsMediaSource = new MediaSource();
  ttsObjectUrl = URL.createObjectURL(ttsMediaSource);
  ttsAudioEl.src = ttsObjectUrl;

  ttsMediaSource.addEventListener("sourceopen", () => {
    ttsSourceBuffer = ttsMediaSource.addSourceBuffer("audio/mpeg");
    ttsSourceBuffer.mode = "sequence";
    ttsSourceBuffer.addEventListener("updateend", flushTtsBufferQueue);
    flushTtsBufferQueue();
  }, { once: true });

  return true;
}

这段代码的含义是:

  • MediaSource 提供一个可动态追加媒体内容的容器
  • SourceBuffer 负责真正追加二进制音频片段
  • sequence 模式意味着按顺序拼接音频流

接下来是关键的队列刷新逻辑:

function flushTtsBufferQueue () {
  if (!ttsSourceBuffer || !ttsMediaSource) return;
  if (ttsSourceBuffer.updating) return;

  if (ttsPendingBuffers.length > 0) {
    const next = ttsPendingBuffers.shift();
    if (next) {
      ttsSourceBuffer.appendBuffer(next);
      if (ttsAudioEl.paused) {
        ttsAudioEl.play().catch(() => {
          setStatus("语音已就绪,请点击播放器开始播报");
        });
      }
    }
    return;
  }

  if (ttsStreamFinal && ttsMediaSource.readyState === "open") {
    try {
      ttsMediaSource.endOfStream();
    } catch {
      // ignore
    }
  }
}

这一段是前端流式音频播放的关键:

  • 如果 SourceBuffer 还在更新,就不能继续 append
  • 如果还有待处理音频帧,就一帧一帧追加
  • 如果流结束了并且队列空了,再 endOfStream

很多人流式播放做不顺,问题就出在这里:不是收不到数据,而是 append 时序没管好。


十五、为什么前端要先建立 TTS WebSocket,再发起 AI SSE 请求

public/asr-stream.html 里,有一个很重要的设计顺序:

await ensureTtsConnection();
await streamAiReply(trimmed);

这个顺序不是随便写的。

原因是:SSE 一旦启动,大模型文本可能马上就开始输出,而文本一输出,服务端就会尝试把 chunk 转发给 TTS。如果这时候前端的 TTS WebSocket 还没准备好,就会出现:

  • 文字已经开始显示
  • 语音通道 session 还没拿到
  • 结果最前面几段音频可能丢失或延迟很大

所以更稳妥的做法是:

  1. 先准备好 TTS WS 通道和 ttsSessionId
  2. 再发起 EventSource 文本流请求
  3. 文本流和音频流用同一个 session 关联

这就是实时系统里的经典原则:

先准备消费端,再启动生产端。


十六、配置项不是“填上就行”,它们决定了系统的行为边界

这个项目里有两组配置文件:

  • src/ai/ai.config.ts
  • src/speech/speech.config.ts

比如模型配置:

const apiKey =
  configService.get<string>('DASHSCOPE_API_KEY') ??
  configService.get<string>('OPENAI_API_KEY');
const baseURL =
  configService.get<string>('DASHSCOPE_BASE_URL') ??
  configService.get<string>('OPENAI_BASE_URL') ??
  'https://dashscope.aliyuncs.com/compatible-mode/v1';
const model = configService.get<string>('MODEL_NAME') ?? 'qwen-plus';

这段写法的工程意义是:

  • 兼容 OpenAI 风格 SDK
  • 允许底层实际使用通义千问兼容接口
  • 模型供应商可替换,但上层调用保持稳定

这比把云厂商调用细节直接写死在业务逻辑里要强得多。

再比如语音配置:

const secretId =
  configService.get<string>('TENCENT_SECRET_ID') ??
  configService.get<string>('SECRET_ID');
const secretKey =
  configService.get<string>('TENCENT_SECRET_KEY') ??
  configService.get<string>('SECRET_KEY');
const appId =
  configService.get<string>('TENCENT_APP_ID') ??
  configService.get<string>('APP_ID');

这个 fallback 设计也挺实用:既兼容语音模块自己的命名,又兼容已有环境变量命名,方便从脚本实验迁移到 NestJS 服务。

此外,这些参数背后都有实际含义:

  • MODEL_NAME:决定回答质量、速度与成本
  • TTS_VOICE_TYPE:决定音色,不只是“换个声音”这么简单,也会影响风格一致性
  • SampleRate:影响音频兼容性与传输成本
  • Codec: mp3:直接决定浏览器流式播放的适配路线

参数不是配置表,而是系统行为控制面。


十七、这套方案做对了什么

如果从技术博客的视角总结,这个项目最值得肯定的地方有五个。

1. 先把 ASR 和完整语音链路分开验证

asr.html 专注于验证录音上传与识别,asr-stream.html 才负责完整交互。这样调试效率非常高。

2. 给文本和音频分别选了合适的协议

  • 文本:SSE
  • 音频:WebSocket

这是正确的边界划分。

3. 用事件总线把 AI 和 TTS 解耦

这一步让整个系统有了继续演化的空间。

4. 文本分段策略考虑了“可听性”

这意味着作者不是只想“跑通”,而是在意真实体验。

5. 前端流式播放没有偷懒

很多 Demo 到 TTS 就退回“整段音频播放”,而这个项目真的做了 MediaSource + SourceBuffer,这是它最接近真实产品体验的地方。


十八、但如果你要把它推进到真实业务,还差哪些东西

这套方案已经很适合作为教程和演示项目,但离生产级还有明显距离。

1. 目前 ASR 仍然是上传式,不是流式

这意味着:

  • 用户必须说完再等识别
  • 无法做边说边识别
  • 无法做更自然的打断式交互

如果你的场景是客服、通话机器人、实时陪练,这会成为瓶颈。

2. 缺少 VAD(静音检测)

目前录音停止主要靠用户点击按钮。真实产品里通常会结合静音检测、自动截断、超时策略,减少用户操作成本。

3. 缺少更完整的错误恢复

比如:

  • 腾讯云 TTS 中途断开如何重连
  • SSE 断流后是否要补偿
  • session 超时如何清理
  • 客户端页面刷新后旧连接如何回收

4. 缺少鉴权与限流

语音系统很容易被滥用,因为它天然涉及高成本外部服务。如果不加认证、配额和频率限制,线上风险会很高。

5. 缺少可观测性

真正的语音体验优化,一定离不开下面这些指标:

  • 录音时长
  • ASR 耗时
  • LLM 首 token 延迟
  • TTS 首包延迟
  • 前端开始播放时间
  • 整体对话完成耗时

如果没有这些指标,你只能凭感觉优化,最后会越改越盲。


十九、这套方案最容易踩的坑,我建议你提前避开

坑 1:前端录音格式和云端识别格式不一致

这是最常见的问题之一。一定要确认:

  • 浏览器录了什么格式
  • Blob.type 是什么
  • 服务端告诉 ASR 的 VoiceFormat 是什么

这三者必须对齐。

坑 2:把每个 token 都立即送进 TTS

这样会让语音像卡壳一样,一两个字就停一次。分段策略一定要做。

坑 3:没有处理浏览器自动播放限制

前端已经做了:

ttsAudioEl.play().catch(() => {
  setStatus("语音已就绪,请点击播放器开始播报");
});

这就是在兜 autoplay 被拦截的情况。很多人本地测得好好的,上线后却发现“没声音”,原因就在这。

坑 4:文本流和音频流没有共享 session

如果没有 ttsSessionId 这层关联,多人并发时非常容易串音。

坑 5:流结束时没有明确收尾

不管是 SSE、TTS 还是前端 MediaSource,你都不能只处理“进行中”,还必须处理:

  • 何时 flush 剩余数据
  • 何时发送 complete
  • 何时 endOfStream
  • 何时 close session

实时系统最怕的不是报错,而是没报错但尾巴没收干净


二十、如果是我继续演进这套系统,我会怎么做

如果你的目标不是只做 Demo,而是把它向更真实的产品推进,我会建议按下面的顺序演进。

第一步:补观测,而不是先改架构

先量化链路延迟,知道瓶颈在哪。

第二步:把上传式 ASR 升级成流式 ASR

这样可以减少等待感,让语音输入更像自然对话。

第三步:增加打断能力

比如:

  • 用户说新问题时,当前 TTS 立刻中断
  • 前端停止播放并清空后续 buffer
  • 服务端结束当前 session

第四步:把 TTS Relay 独立成可复用的语音网关

当前它是项目内 service,但未来完全可以变成一个独立语音中继层,为多个 AI 应用复用。

第五步:把知识库/RAG 接进来

当语音链路稳定后,真正决定业务价值的就不再是“会不会说话”,而是“回答是否可靠”。

到那一步,系统的重点就会从“多流协同”继续延伸到“检索增强 + 语音交互”的组合能力。


二十一、我的最终判断:这类语音 Agent 的默认方案,应该怎么选

如果你的目标是:

  • 做一个可演示、可讲解、可扩展的语音 AI 助手
  • 让用户获得“能说、能看、能听”的闭环体验
  • 不想一上来就被全双工实时语音系统的复杂度拖死

那么我认为这套方案是非常合适的默认起点:

  • 输入:上传式 ASR
  • 文本输出:SSE
  • 语音输出:WebSocket 流式 TTS
  • 服务端协同:事件桥接
  • 前端播放:MediaSource + SourceBuffer

它不是终极架构,但它在复杂度、教学价值和可演进性之间取得了很好的平衡。

反过来说,如果你的目标是:

  • 电话机器人
  • 实时会议同传
  • 全双工强交互语音陪练
  • 极低延迟语音中断

那这套方案就只是过渡阶段,你迟早会走向:

  • 流式 ASR
  • 更强的状态机
  • 更复杂的音频管线
  • 更严格的会话与时延控制

所以,不要把“是否先进”当成选型标准,而要把“当前场景最需要解决什么问题”当成标准。


总结

回到文章开头的那个结论。

很多人做语音 AI 助手时,会把注意力放在:

  • 接哪家 ASR
  • 接哪家大模型
  • 接哪家 TTS

这些当然重要,但它们不是核心矛盾。

真正决定体验和工程质量的,是你能不能把下面这几件事组织成一个协同系统:

  • 录音上传与格式适配
  • 文本流式生成
  • 文本到语音的分段策略
  • SSE 与 WebSocket 的职责分离
  • 服务端事件桥接
  • 前端流式音频播放
  • 生命周期、会话和收尾处理

换句话说,语音 Agent 的本质不是“多接两个接口”,而是“多条流如何在正确的时机,用正确的协议,完成一次完整协作”。

而这,正是这份 NestJS 项目最值得学习的地方。

如果你后面还想继续扩展,我建议下一篇就顺着这条线往下写:

  1. 把上传式 ASR 升级成流式 ASR
  2. 增加打断、重说与会话中止能力
  3. 接入 RAG,让它从“会说话”升级成“会回答业务问题”

到那时,这就不只是一个语音 Demo,而会成为一个真正有业务形态的 AI 应用底座。

Prisma 实战指南:像搭积木一样设计古诗词数据库

作者 Lee川
2026年5月3日 15:40

Prisma 实战指南:像搭积木一样设计古诗词数据库

在传统后端开发中,与数据库打交道往往意味着要编写大量晦涩的 SQL 语句。而 Prisma 就像一位精通多国语言的“翻译官”,它通过 ORM(对象关系映射)技术,将数据库的表映射为代码中的类,将行映射为实例。你不再需要手写 INSERTSELECT,只需像操作普通对象一样 createfindMany,Prisma 就会在幕后为你翻译成精准的 SQL。

接下来,我们就结合一个“古诗词社区”的实际项目,从零开始体验 Prisma 的魅力。

一、环境搭建与初始化

首先,我们需要为项目安装 Prisma 的核心依赖。建议锁定版本以避免兼容性问题:
pnpm i prisma@6.19.2
pnpm i @prisma/client@6.19.2

依赖安装完毕后,执行 npx prisma init。这条命令会为你生成两个关键文件:.env(存放环境变量)和 prisma/schema.prisma(数据库设计蓝图)。

打开 .env,填入你的 PostgreSQL 连接字符串,例如:
DATABASE_URL="postgresql://postgres:369369@localhost:5432/xue?schema=public"

二、Schema 设计:绘制数据库蓝图

schema.prisma 是整个 ORM 的灵魂。在这个文件中,我们通过 model 来定义数据表。让我们结合古诗词项目的实际设计,看看几个核心模型是如何构建的:

1. 基础配置与用户模型
文件头部定义了生成器和数据源,告诉 Prisma 我们要生成 JS 客户端并连接 PostgreSQL。

generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
model User {
  id        Int      @id @default(autoincrement())
  name      String   @unique @db.VarChar(255)
  password  String   @db.VarChar(255)
  // 使用 @map 将驼峰字段映射为数据库的下划线命名
  createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6)
  // 一对多关系:一个用户可以有多篇文章、评论、点赞等
  posts     Post[]
  comments  Comment[]
  likes     UserLikePost[] 
  files     File[]
  avatars   Avatar[]
  @@map("user") // 将表名映射为单数 user
}

2. 核心业务与级联策略
Post(诗词文章)模型中,我们看到了外键关联与删除策略的精妙配合:

model Post {
  id       Int     @id @default(autoincrement())
  title    String  @db.VarChar(255)
  content  String? @db.Text
  userId   Int?             
  // 关联 User,并设置 onDelete: SetNull
  // 意为:如果作者被删除,文章保留但作者ID置空
  user     User?   @relation(fields: [userId], references: [id], onDelete: SetNull) 
  comments Comment[]
  tags     PostTag[]
  @@index([userId]) // 为外键添加索引,提升查询效率
  @@map("posts")
}

3. 复杂关联:自关联与复合主键
古诗词社区少不了评论互动与标签分类,这里用到了两个高级技巧:

  • 自关联(评论回复) :在 Comment 模型中,通过 parentId@relation("CommmentToComment") 实现了评论的层级回复(父评论与子评论)。
  • 复合主键(多对多中间表)PostTag(文章标签)和 UserLikePost(用户点赞)作为中间表,使用 @@id([postId, tagId]) 定义了复合主键。这确保了“一篇文章不能被重复打同一个标签”以及“一个用户不能重复点赞同一篇文章”的业务逻辑。

三、迁移与可视化:让设计落地

设计好 Schema 后,我们需要将其同步到真实的数据库中。

  1. 数据迁移:执行 npx prisma migrate dev --name init_user。Prisma 会自动对比当前数据库结构,生成 SQL 迁移文件并执行,同时在数据库中记录版本日志。这不仅方便团队协作,也方便后续的版本回滚。
  2. 可视化操作:执行 npx prisma studio。这会打开一个精美的图形化界面,你可以在浏览器中直观地查看 UserPost 等表的数据,甚至手动添加测试数据(Seeds),完全告别黑乎乎的命令行。

四、代码操作:告别 SQL

当一切准备就绪,你就可以在代码中通过 Prisma Client 优雅地操作数据了。例如,查询李白发布的所有诗词:

const libaiPosts = await prisma.post.findMany({
  where: { user: { name: 'libai' } },
  include: { tags: true } // 顺带查出文章标签
});

从安装配置到模型设计,再到最终的代码调用,Prisma 用类型安全和高度抽象的 API,将开发者从繁琐的 SQL 中彻底解放了出来。

iOS应用上架全流程:从证书申请到发布避坑指南

2026年4月21日 13:23

iOS上架全流程避坑指南速存!

作为一名独立开发者,今天来和大家分享一下将「楼里」这款应用从iOS打包到上架的全流程。iOS打包到上架,对个人开发者来说就像“九九八十一难”,但只要一步步来,也能顺利完成。下面,我会毫无保留地分享每一个关键步骤。

证书申请篇

  1. 准备一台Mac电脑:这是前提条件,没有Mac的同学可能需要借力或者购买云服务。

  2. 申请苹果开发者账号:费用为688元/年,这是开启iOS开发大门的钥匙。

  3. 证书生成:苹果官方提供了Certificates、Identifiers、Profiles的申请流程,建议自己本地生成.p12私钥证书,这样更安全也更方便后续操作。

此外,使用工具如AppUploader可以在Windows、Linux或Mac系统中直接申请iOS证书,无需依赖Mac电脑,简化证书管理流程。

ICP备案篇

  1. 准备服务器:有免费和付费的选择,根据自己的需求来。

  2. 申请域名:域名价格差异大,好的域名更贵,建议提前规划。

  3. 备案流程:选择App备案,分为初审(平台审)和终审(管局审),正常情况下7天内可以通过。全国互联网安全管理服务平台是备案的重要一环,特别是产品功能基本开发完毕后,审核人员会仔细查看产品。如果App/小程序,还需要线下面签;如果是网站,则可以线上完成。

App打包篇

  1. 使用UniApp开发,打包工具为HBuilderX,这可以大大提高开发效率。

  2. App图标处理:去掉Alpha通道,确保图标显示正常。

  3. 启动界面:创建自定义的storyboard作为启动界面,提升用户体验。

  4. 广告标识:去掉使用广告标识(IDFA)的勾选,保护用户隐私。

  5. 云打包:使用申请的证书文件进行云打包,用回复的链接下载iOS安装包。

发布上架篇

  1. 下载Transporter工具:这是苹果官方提供的安装包交付工具,确保安装包能够顺利提交。

或者使用AppUploader工具上传IPA文件到App Store,支持在Windows、Mac或Linux系统上操作,无需Mac电脑,比Transporter更高效,且能批量上传应用截图和管理描述信息。

  1. 资料准备:准备产品的10张截图、推广文本、描述、关键词等资料,这些都是审核的重要依据。

  2. 图标与截图:App图标大小为1024x1024,直角边,确保在各设备上显示效果最佳。

  3. 隐私政策与技术支持:提供隐私政策网址 (URL) 和技术支持网址 (URL),可以用github或Notion搭建静态网站,方便用户查看。

  4. App供应情况:按情况填需要上架的地区,确保应用能够在目标市场上线。

整个流程下来,虽然复杂,但只要一步步来,每一个细节都处理到位,就能够成功将应用上架到iOS平台。希望今天的分享能够对大家有所帮助。

❌
❌