本章总结了我们在真实 Agent 项目中踩过的工程化大坑。每个坑点按「症状 → 原因 → 解法 → 预防」结构组织,遇到类似问题可以直接对号入座。
坑 1:Monorepo 依赖装错位置
症状
Error: Cannot find module 'xxx'
明明 pnpm install 跑过了,但某个子项目就是找不到依赖。或者装了一个包之后,另一个子项目莫名其妙也受影响了。
原因
在 monorepo 根目录直接运行 pnpm add xxx,包会被装到根 package.json,而不是你想要的子项目里。pnpm workspace 的隔离机制意味着每个子项目只能访问自己声明的依赖。
解法
永远使用 --filter 指定目标项目:
# ✅ 正确:给 web 子项目装依赖
pnpm --filter web add naive-ui
# ✅ 正确:给 server 子项目装开发依赖
pnpm --filter server add -D @types/node
# ❌ 错误:在根目录直接装
pnpm add naive-ui
如果已经装错了,手动从根 package.json 中删除对应条目,再用 --filter 重新安装。
预防
在 CLAUDE.md 或项目文档中明确写下这条规则,让团队成员(包括 AI 助手)都遵守。
坑 2:TypeScript 构建缓存导致"幽灵错误"
症状
error TS6305: Output file '/packages/types/dist/api.d.ts' has not been built from source file '/packages/types/src/api.ts'.
或者更诡异的情况:dist 目录里只有部分文件,明明有 8 个模块,却只构建出了 1 个。
原因
TypeScript 的增量编译("incremental": true)会生成 tsconfig.tsbuildinfo 缓存文件。这个缓存记录了"哪些文件已经编译过了"。问题在于:
- 如果
tsbuildinfo文件过期或损坏,TypeScript 会错误地认为输出已经是最新的,跳过编译 - 如果这个缓存文件被提交到 Git,不同环境(本地 / Docker / CI)之间会互相干扰
我们的真实案例:packages/types/dist/ 里只有 1 月份构建的 project 模块,其他 7 个模块全部缺失,但 TypeScript 认为"不需要重新编译"。
解法
# 清除缓存,强制完整重新编译
rm -rf packages/types/dist packages/types/tsconfig.tsbuildinfo
cd packages/types && npx tsc --build
预防
- 把
tsbuildinfo加入.gitignore:
# TypeScript 构建缓存 —— 绝对不要提交到版本控制
*.tsbuildinfo
- 在 CI/Docker 构建中,总是先清缓存再构建:
# Dockerfile 中
RUN rm -rf packages/types/tsconfig.tsbuildinfo && \
pnpm --filter @testcase/types build
坑 3:共享类型包构建不全
症状
Cannot find module '@testcase/types' or its corresponding type declarations.
Docker 构建或 CI 里报错,但本地开发完全正常。
原因
Monorepo 中的共享类型包(如 packages/types)需要先编译,其他子项目才能引用。本地开发时 IDE 直接读源码所以没问题,但构建环境需要编译产物。
常见的失败链条:
types包源码改了,但没重新builddist/目录存在旧文件,缺少新增的模块- 下游项目引用新增的导出时报 "Cannot find module"
解法
# 确保类型包完整构建
rm -rf packages/types/dist
pnpm --filter @testcase/types build
# 验证输出完整性 —— 检查 dist 目录里是否每个源文件都有对应的 .js 和 .d.ts
ls packages/types/dist/
# 应该看到: api.js api.d.ts generation.js generation.d.ts ... index.js index.d.ts
预防
在构建脚本中加入清理步骤,或者在 CI pipeline 里强制全量构建。
坑 4:Docker 多阶段构建中模块解析失败
症状
Docker 本地构建一切正常,但推送到 CI 后构建失败,报各种模块找不到的错误。
原因
Docker 多阶段构建的每一阶段是独立的文件系统。常见问题:
- COPY 顺序错误 — 先复制了代码,但
node_modules还没装 - workspace 链接丢失 — pnpm workspace 的软链接在 Docker 层之间不生效
- 构建缓存过期 — Docker 层缓存了旧的
node_modules,新增的依赖没安装
解法
一个经过验证的 Dockerfile 结构:
FROM node:20-slim AS base
RUN npm i -g pnpm
# 第一步:只复制 package.json 文件(利用 Docker 缓存)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
COPY packages/types/package.json ./packages/types/
# 第二步:安装依赖
RUN pnpm install --frozen-lockfile
# 第三步:复制源码
COPY . .
# 第四步:按顺序构建(types 必须在其他项目之前)
RUN rm -rf packages/types/tsconfig.tsbuildinfo && \
pnpm --filter @testcase/types build && \
pnpm --filter web build
关键点:types 包必须先于其他项目构建。 这个顺序在本地开发时不明显(IDE 直接读源码),但在 Docker 中是硬性要求。
预防
在 CLAUDE.md 或项目文档里记录 Docker 构建的正确顺序,避免新成员踩坑。
坑 5:Prisma Schema 改了但忘了 generate
症状
TypeError: Cannot read properties of undefined (reading 'findMany')
// 或者
PrismaClientValidationError: Unknown arg `newField` in data.newField
改了 schema.prisma 加了新字段或新模型,代码也写好了,但运行时报错。
原因
Prisma 的工作流是两步走的:
prisma db push— 把 schema 变更同步到数据库(加表/加字段)prisma generate— 根据 schema 重新生成 Prisma Client(TypeScript 类型和查询方法)
很多人只做了第一步(数据库确实变了),忘了第二步(代码里的 Prisma Client 还是旧的)。
解法
# 两步必须都做,而且顺序不能反
cd apps/server
npx prisma db push # 同步到数据库
npx prisma generate # 重新生成客户端
如果需要部署到生产环境,还要创建迁移 SQL:
# 迁移文件放在指定目录,运维直接执行
# 文件命名:日期_描述.sql
cat > prisma/migrations/pending/20260309_add_user_avatar.sql << 'EOF'
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
EOF
预防
建立一个固定的 schema 变更清单:
□ 修改 schema.prisma
□ 运行 prisma db push
□ 运行 prisma generate
□ 创建迁移 SQL 文件(如果要上生产)
□ 重启开发服务器
把这个清单写进项目文档,每次改 schema 都对照执行。
坑 6:LLM 输出解析失败
症状
SyntaxError: Unexpected token '<' in JSON at position 0
// 或者
JSON.parse 拿到的结果缺少字段、格式不对
你让 LLM "输出 JSON 格式",但它返回的内容解析失败。
原因
LLM 是概率模型,不是 JSON 生成器。常见的输出问题:
- 在 JSON 前后加了 markdown 代码块标记(
json ...) - 输出被截断(max_tokens 不够)
- 某些字段值包含未转义的引号或换行符
- 偶尔"抽风"输出完全无关的内容
解法
不要信任 LLM 的原始输出,始终做防御性解析:
function safeParseLLMOutput(raw: string): object | null {
// 第 1 层:尝试直接解析
try { return JSON.parse(raw); } catch {}
// 第 2 层:去掉 markdown 代码块标记
const cleaned = raw.replace(/```(?:json)?\s*/g, '').replace(/```\s*$/g, '');
try { return JSON.parse(cleaned); } catch {}
// 第 3 层:提取第一个 JSON 对象/数组
const match = cleaned.match(/[\[{][\s\S]*[\]}]/);
if (match) {
try { return JSON.parse(match[0]); } catch {}
}
// 第 4 层:修复常见的 JSON 格式问题
try {
const fixed = cleaned
.replace(/,\s*([}\]])/g, '$1') // 去掉末尾逗号
.replace(/'/g, '"'); // 单引号转双引号
return JSON.parse(fixed);
} catch {}
// 全部失败
console.error('LLM 输出解析失败:', raw.substring(0, 200));
return null;
}
预防
- 在 Prompt 中明确要求格式: "请直接输出 JSON,不要包含任何其他文字或代码块标记"
- 设置足够的 max_tokens: 别因为截断导致 JSON 不完整
- 使用支持结构化输出的 API 特性: 如 OpenAI 的
response_format: { type: "json_object" }
最佳实践总结
| # | 实践 | 一句话说明 |
|---|---|---|
| 1 | 用 --filter 管理依赖 | monorepo 下不在根目录装包 |
| 2 | 不提交构建缓存 | .tsbuildinfo 加入 .gitignore |
| 3 | 共享包先构建 | types 包永远在其他项目之前编译 |
| 4 | Docker 构建顺序固定 | package.json → install → 源码 → 按序构建 |
| 5 | Schema 变更走清单 | push + generate + 迁移 SQL 三步走 |
| 6 | LLM 输出防御性解析 | 永远不要直接 JSON.parse |
核心原则:每多一层复杂度,就多一个出 Bug 的入口。能简单就不要复杂。