AI Agent 开发避坑指南:6 个真实踩过的工程化大坑

11 阅读6分钟

本章总结了我们在真实 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 缓存文件。这个缓存记录了"哪些文件已经编译过了"。问题在于:

  1. 如果 tsbuildinfo 文件过期或损坏,TypeScript 会错误地认为输出已经是最新的,跳过编译
  2. 如果这个缓存文件被提交到 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

预防

  1. tsbuildinfo 加入 .gitignore
# TypeScript 构建缓存 —— 绝对不要提交到版本控制
*.tsbuildinfo
  1. 在 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 直接读源码所以没问题,但构建环境需要编译产物。

常见的失败链条:

  1. types 包源码改了,但没重新 build
  2. dist/ 目录存在旧文件,缺少新增的模块
  3. 下游项目引用新增的导出时报 "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 多阶段构建的每一阶段是独立的文件系统。常见问题:

  1. COPY 顺序错误 — 先复制了代码,但 node_modules 还没装
  2. workspace 链接丢失 — pnpm workspace 的软链接在 Docker 层之间不生效
  3. 构建缓存过期 — 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 的工作流是两步走的:

  1. prisma db push — 把 schema 变更同步到数据库(加表/加字段)
  2. 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 生成器。常见的输出问题:

  1. 在 JSON 前后加了 markdown 代码块标记(json ...
  2. 输出被截断(max_tokens 不够)
  3. 某些字段值包含未转义的引号或换行符
  4. 偶尔"抽风"输出完全无关的内容

解法

不要信任 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;
}

预防

  1. 在 Prompt 中明确要求格式: "请直接输出 JSON,不要包含任何其他文字或代码块标记"
  2. 设置足够的 max_tokens: 别因为截断导致 JSON 不完整
  3. 使用支持结构化输出的 API 特性: 如 OpenAI 的 response_format: { type: "json_object" }

最佳实践总结

#实践一句话说明
1--filter 管理依赖monorepo 下不在根目录装包
2不提交构建缓存.tsbuildinfo 加入 .gitignore
3共享包先构建types 包永远在其他项目之前编译
4Docker 构建顺序固定package.json → install → 源码 → 按序构建
5Schema 变更走清单push + generate + 迁移 SQL 三步走
6LLM 输出防御性解析永远不要直接 JSON.parse

核心原则:每多一层复杂度,就多一个出 Bug 的入口。能简单就不要复杂。