Skip to content

第 10 章 全栈功能开发:Agent 级自主权

版本基准:2026-07-08 核对,Claude Code v2.1.202+,模型 Opus 4.8 / Fable 5 / Sonnet 5 / Haiku 4.5。

把一个有验收条件的任务交给 Claude Code,让它独立完成"后端接口 + 权限中间件 + 数据库读取 + 前端页面 + 回归测试"。这是 Agent 级自主权的典型场景,也是工程篇的综合演练——前面几章的可执行闭环、Plan Mode、精准指令、重构与 Bug 修复,在这里组装成一次端到端交付。

10.1 结论:全栈开发是把 Claude Code 当 Agent 用的典型场景

单面能力只有组装成完整交付才体现 Agent 价值。全栈新功能开发就是这样一个场景:你交付一个有验收条件的任务,让 Claude Code 完成"前后端 + 测试"的端到端实现,而不是逐行指挥它写每一行代码。

四条核心判断:

  • Agent 级自主权意味着你定义"用户可观察到的结果"和"边界",Claude 决定路由文件名、组件拆分、数据访问层函数名、测试 API。人类定义 What 与 Why,机器定义 How。自主权不是"放着不管",而是把人类注意力从低价值审批(文件叫什么名、函数怎么拆)抽离,留给不可自动判定的决策(权限语义、安全边界、架构取舍)。
  • 分步交付比一次性生成整个功能高效——先接口、再前端、再测试,每步都有可验证的中间状态,出问题能快速定位。一次性生成 8 个文件的代价是:一旦中间某层出错,排查要在 8 个文件之间跳,而分步交付把排查范围锁死在刚改的那一层。
  • 改已有代码比重新生成高效——让 Agent 复用现有的权限 helper、API client、分页结构,而不是另起炉灶。重新生成是 LLM 的默认倾向,必须用约束压住。复用现有模式还带来一个隐性收益:新代码与老代码风格一致,后续维护成本低。
  • 完成标准必须机器可检查——测试通过、lint 干净、typecheck 无误,而不是 Claude 说"完成了"。这条纪律在第 06 章已确立,本章把它落到全栈场景。全栈场景的"完成"比单层更难判定,因为前后端都可能各自通过单测,但集成处断裂——所以验证必须包含端到端的 npm test 全量跑。

理解这四条的标准:如果你能在任务 Prompt 里把"目标、范围、约束、验证、完成输出"五段都填实,且每段都可机器检查或精确投喂,这个任务就适合给 Agent 级自主权。填不实,说明任务还没想清楚,先用 Plan Mode(第 07 章)想清楚再交付。

本章案例:在一个 Express + React monorepo 中,新增带权限校验的审计日志接口。这是真实工程里很常见的全栈任务:有数据读取、有权限边界、有前端展示、有回归测试,恰好覆盖前后端与测试三层。

10.2 案例:带权限校验的审计日志接口

现有 monorepo 结构:

text
acme-console/
├── apps/
│   ├── api/                    # Express 后端
│   │   └── src/
│   │       ├── app.ts
│   │       ├── middleware/require-user.ts
│   │       ├── routes/users.ts
│   │       └── repositories/user-repository.ts
│   └── web/                    # React 前端(Vite + vitest)
│       └── src/
│           ├── api/client.ts
│           ├── pages/UserDetailPage.tsx
│           └── components/
├── packages/
│   ├── auth/src/can.ts         # 共享权限策略
│   └── contracts/src/index.ts  # 共享类型
└── package.json

交付目标:

  • 新增 GET /api/users/:userId/audit-events,从 audit_events 表读取数据;
  • 只有 admin 或用户本人可以查看;
  • 后端限制最大分页大小(100),默认 25;
  • 前端在用户详情页展示时间、操作者、动作;
  • 未授权统一返回 403,不泄露用户是否存在;
  • 用 vitest + supertest 写 API 回归测试,用 vitest + Testing Library 写前端测试。

10.3 任务 Prompt:用 06 章骨架给全栈场景的完整验收条件

第 06 章给过一个任务 Prompt 骨架:目标 / 范围 / 约束 / 验证 / 完成输出。这里把它落成全栈场景的完整版本。这是本章最值得复用的资产——把骨架填实,任务就成了一半。

注意:/dev 不是官方内置命令(见第 06 章命令真实性总表 E 类)。如果你没安装自定义 /dev skill,直接用下面的自然语言 Prompt:

text
目标:
在用户详情页增加审计日志,展示该用户的历史操作记录。

范围:
- apps/api/src/routes/ (新增路由)
- apps/api/src/repositories/ (新增查询)
- apps/api/src/app.ts (挂载路由)
- apps/web/src/api/ (新增 API client 方法)
- apps/web/src/components/ (新增组件)
- apps/web/src/pages/UserDetailPage.tsx (挂载组件)
- packages/contracts/src/ (共享类型)
- packages/auth/src/can.ts (权限策略)
- 相关 *.test.ts 文件

约束:
- 只有 admin 或用户本人可访问,未授权统一返回 403
- 不新增依赖,不改数据库 schema(只读现有 audit_events 表)
- 沿用现有分页响应结构 { items, nextCursor }
- 复用现有 require-user 中间件和 @acme/auth 的策略函数风格
- 前端复用现有 apiClient,不引入 React Query

验证(必须全部运行,exit 0 才算完成):
- npm test -- user-audit-events
- npm test -- AuditEventList
- npm run lint
- npm run typecheck
- npm test

完成输出:
- 变更摘要(改了什么、为什么)
- 文件清单
- 验证结果(每条命令的 exit code)
- 剩余风险(未覆盖的场景、潜在副作用)

失败时读取错误并继续修复,直到全部通过或遇到必须由人类决策的阻塞。

这个 Prompt 的关键设计:

  • 目标用用户结果描述,不指定实现方式——"展示历史操作记录"而不是"加一个 table"。
  • 范围用目录列表,不用"看一下项目"——精确投喂能减少幻觉(第 06 章支点一)。
  • 约束写死边界——"不新增依赖""不改 schema""复用 apiClient"这些是 LLM 默认会越界的点,必须显式挡住。
  • 验证是机器可检查的命令——五条命令全部 exit 0 才算完成,Claude 自己跑、自己读失败、自己修。
  • 完成输出是结构化的——不是"功能已完成",而是结果 + 证据 + 边界三段式。

它刻意不指定路由文件名、组件拆分方式、repository 函数名、测试 API。这些是 Agent 应从代码库现状推断的实现细节。

10.4 探索阶段:让 Agent 自己发现项目模式

Agent 接到任务后第一轮应该是探索,而不是直接写代码。模拟执行轨迹(终端转录为示例,不暗示来自真实仓库):

text
● Read(CLAUDE.md)
● Read(package.json)
● Read(apps/api/src/routes/users.ts)
● Read(apps/api/src/middleware/require-user.ts)
● Read(apps/api/src/repositories/user-repository.ts)
● Read(packages/auth/src/can.ts)
● Grep("pagination", path="apps/api/src")
● Grep("UserDetailPage", path="apps/web/src")
● Read(apps/web/src/api/client.ts)
● Read(apps/web/src/pages/UserDetailPage.tsx)
● Glob("**/*user*.test.*")

预期输出——Agent 应给出一个短计划,而不是一篇架构论文:

text
Plan:
1. Add shared AuditEvent contract in packages/contracts.
2. Add canViewUserAudit policy in packages/auth.
3. Add auditEventRepository.listForUser (reads audit_events table).
4. Add GET /api/users/:userId/audit-events route, mounted in app.ts.
5. Add typed API client method getUserAuditEvents.
6. Add AuditEventList component, mount on UserDetailPage.
7. Add API tests (auth + pagination) and component tests.
8. Run targeted tests, then lint, typecheck, full tests.

人类此时只检查两件事:

  1. 它是否找到了现有权限入口(require-user + can.ts),而不是准备手写一套新权限逻辑;
  2. 它是否误触数据库 schema、依赖升级或全局 UI 重构。

如果计划里出现"引入 React Query"或"添加 migrations",立即纠偏——这是范围漂移的早期信号,越早挡成本越低。

10.5 后端:API 端点 + 权限中间件 + 数据库读取

10.5.1 共享类型与权限策略

共享 DTO 放在 packages/contracts,前后端共享,避免类型漂移:

ts
// packages/contracts/src/audit-event.ts
export interface AuditEventDto {
  id: string;
  actorName: string;
  action: string;
  createdAt: string;
}

export interface AuditEventPageDto {
  items: AuditEventDto[];
  nextCursor: string | null;
}

权限策略沿用现有 can.ts 的风格,纯函数、可单测:

ts
// packages/auth/src/can.ts
export interface AuthUser {
  id: string;
  role: "admin" | "member";
}

export function canViewUserAudit(
  actor: AuthUser,
  targetUserId: string
): boolean {
  return actor.role === "admin" || actor.id === targetUserId;
}

10.5.2 数据库读取层

假设现有 repository 用 pg 客户端,沿用现有 user-repository.ts 的模式。关键工程决策:参数化查询、游标分页、显式类型标注。

ts
// apps/api/src/repositories/audit-event-repository.ts
import type { Pool } from "pg";
import type { AuditEventPageDto, AuditEventDto } from "@acme/contracts";

interface ListParams {
  userId: string;
  cursor: string | undefined;
  limit: number;
}

interface Row {
  id: string;
  actor_name: string;
  action: string;
  created_at: Date;
}

export function createAuditEventRepository(pool: Pool) {
  return {
    async listForUser({
      userId,
      cursor,
      limit
    }: ListParams): Promise<AuditEventPageDto> {
      const params: unknown[] = [userId, limit + 1];
      let query = `
        SELECT id, actor_name, action, created_at
        FROM audit_events
        WHERE user_id = $1
      `;
      if (cursor) {
        query += ` AND created_at < $3`;
        params.push(cursor);
      }
      query += ` ORDER BY created_at DESC LIMIT $2`;

      const result = await pool.query<Row>(query, params as never[]);
      const rows = result.rows;
      const hasMore = rows.length > limit;
      const items: AuditEventDto[] = rows.slice(0, limit).map((r) => ({
        id: r.id,
        actorName: r.actor_name,
        action: r.action,
        createdAt: r.created_at.toISOString()
      }));

      return {
        items,
        nextCursor: hasMore ? items[items.length - 1].createdAt : null
      };
    }
  };
}

几个工程细节必须讲清楚:

  • 参数化查询($1$2),杜绝 SQL 注入——这是红线,不可用字符串拼接。即使用户输入经过了上层校验,数据库层也必须参数化,这是纵深防御;
  • LIMIT $2 用参数而不是拼接,同理——limit 来自 z.coerce.number() 校验过,但参数化是最后一道保险;
  • limit + 1 技巧判断是否有下一页,避免单独 count 查询,一次查询同时拿到数据与翻页信号。这比"先 count 再查"少一次往返,在 audit_events 表大的时候差距明显;
  • created_at 转 ISO 字符串,跨边界传输用字符串不用 Date——JSON 序列化 Date 有时区陷阱,前端拿到 2026-07-08T08:30:00.000Z 比拿到 Date 对象再处理更可控;
  • 显式标注返回类型 AuditEventDto[]——这是 10.8 节调试演示里 typecheck 拦截 bug 的前提。不标注类型,TS 会推断成 any 或宽类型,类型不匹配就漏到运行时;
  • 游标用 created_at 而不是 id——本案例假设同一毫秒不会有两条记录。生产环境如果可能有同毫秒记录,游标应该是 (created_at, id) 复合键,否则会漏数据。这是一个工程权衡,Agent 应在 Remaining risks 里点出来。

10.5.3 路由:权限必须在数据查询前完成

ts
// apps/api/src/routes/user-audit-events.ts
import { Router } from "express";
import { z } from "zod";
import { canViewUserAudit } from "@acme/auth";
import type { AuditEventPageDto } from "@acme/contracts";
import { requireUser } from "../middleware/require-user.js";
import { auditEventRepository } from "../repositories/audit-event-repository.js";

const paramsSchema = z.object({
  userId: z.string().min(1)
});

const querySchema = z.object({
  cursor: z.string().min(1).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(25)
});

export const userAuditEventsRouter = Router({ mergeParams: true });

userAuditEventsRouter.get("/", requireUser, async (req, res, next) => {
  try {
    const actor = req.user;
    const { userId: targetUserId } = paramsSchema.parse(req.params);

    if (!canViewUserAudit(actor, targetUserId)) {
      res.status(403).json({ error: "forbidden" });
      return;
    }

    const query = querySchema.parse(req.query);
    const result: AuditEventPageDto = await auditEventRepository.listForUser({
      userId: targetUserId,
      cursor: query.cursor,
      limit: query.limit
    });

    res.json(result);
  } catch (error) {
    next(error);
  }
});

挂载路由:

ts
// apps/api/src/app.ts
import { userAuditEventsRouter } from "./routes/user-audit-events.js";

app.use("/api/users/:userId/audit-events", userAuditEventsRouter);

为什么先鉴权再查数据,顺序不能反:

text
request

authenticate (require-user)

authorize: canViewUserAudit(actor, targetUserId)

query audit_events table

respond

如果先查目标用户再鉴权,攻击者可能通过 404 / 403 差异枚举用户是否存在(用户存在但无权 → 403,用户不存在 → 404)。统一在数据查询前返回 403,让两种情况返回相同响应,减少信息泄露。这条纪律在测试里要显式证明(见 10.7 节第二个测试)。

10.6 前端:页面 + API 调用 + 状态管理

10.6.1 API client

ts
// apps/web/src/api/audit-events.ts
import type { AuditEventPageDto } from "@acme/contracts";
import { apiClient } from "./client";

export async function getUserAuditEvents(
  userId: string
): Promise<AuditEventPageDto> {
  return apiClient.get(
    `/api/users/${encodeURIComponent(userId)}/audit-events`
  );
}

encodeURIComponent 是必须的——userId 是路径参数,如果不编码,含 /? 的恶意输入会改变路由语义。

10.6.2 组件:把权限错误当作产品状态

tsx
// apps/web/src/components/AuditEventList.tsx
import { useEffect, useState } from "react";
import type { AuditEventDto } from "@acme/contracts";
import { getUserAuditEvents } from "../api/audit-events";

interface AuditEventListProps {
  userId: string;
}

type Status = "loading" | "ready" | "forbidden" | "error";

export function AuditEventList({ userId }: AuditEventListProps) {
  const [events, setEvents] = useState<AuditEventDto[]>([]);
  const [status, setStatus] = useState<Status>("loading");

  useEffect(() => {
    let active = true;
    setStatus("loading");

    getUserAuditEvents(userId)
      .then((result) => {
        if (!active) return;
        setEvents(result.items);
        setStatus("ready");
      })
      .catch((error: { status?: number }) => {
        if (!active) return;
        setStatus(error.status === 403 ? "forbidden" : "error");
      });

    return () => {
      active = false;
    };
  }, [userId]);

  if (status === "loading") return <p>Loading audit events…</p>;
  if (status === "forbidden") return null;
  if (status === "error")
    return <p role="alert">Unable to load audit events.</p>;
  if (events.length === 0) return <p>No audit events.</p>;

  return (
    <ol aria-label="Audit events">
      {events.map((event) => (
        <li key={event.id}>
          <time dateTime={event.createdAt}>
            {new Date(event.createdAt).toLocaleString()}
          </time>
          {" · "}
          <strong>{event.actorName}</strong>
          {" · "}
          {event.action}
        </li>
      ))}
    </ol>
  );
}

几个关键决策:

  • active 标记避免组件卸载后继续 setState——React 老坑,卸载后 setState 会告警;
  • 403 渲染为 null(不显示),而不是报错——对无权限用户来说,这个区域"不存在"比"报错"更合理,也不泄露"这里有东西但你不能看";
  • **error 状态用 role="alert"**让辅助技术感知,这是可访问性基本要求;
  • 没有引入 React Query——任务约束明确说不新增依赖。更成熟的项目可能已有请求库取消机制,Agent 应优先复用现有模式。

状态管理上,这里用四个字面量联合类型 "loading" | "ready" | "forbidden" | "error" 而不是布尔标志组合。原因:布尔组合会产生非法状态(比如 loading=true 同时 error=true),联合类型让每个状态互斥,渲染分支也清晰。这是状态机思维的最小应用——不引入额外库,只用类型系统约束状态空间。如果未来这个组件要加翻页、刷新、重试,状态会扩展到七八个,那时再考虑用 useReducer 或请求库;当前一个只读列表,useState 足够。

10.6.3 页面挂载

tsx
// apps/web/src/pages/UserDetailPage.tsx
import { AuditEventList } from "../components/AuditEventList";

export function UserDetailPage({ userId }: { userId: string }) {
  return (
    <main>
      <UserProfile userId={userId} />
      <section aria-labelledby="audit-heading">
        <h2 id="audit-heading">Audit log</h2>
        <AuditEventList userId={userId} />
      </section>
    </main>
  );
}

注意这里是在已存在文件里加一个 section,不是新建一个 AuditEventPage.tsx。这是 10.9 节"改已有代码比重新生成高效"的体现。

10.7 测试:vitest + supertest 写回归测试

10.7.1 API 权限测试:同时证明允许和拒绝

ts
// apps/api/src/routes/user-audit-events.test.ts
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import { app } from "../app";
import { auditEventRepository } from "../repositories/audit-event-repository";

function authHeader(user: { id: string; role: string }) {
  const token = Buffer.from(JSON.stringify(user)).toString("base64");
  return { Authorization: `Bearer test-${token}` };
}

describe("GET /api/users/:userId/audit-events", () => {
  it.each([
    ["admin", "user-2", 200],
    ["member", "user-1", 200],
    ["member", "user-2", 403]
  ])(
    "%s requesting %s receives %i",
    async (role, targetUserId, expectedStatus) => {
      const actor = { id: "user-1", role };
      const response = await request(app)
        .get(`/api/users/${targetUserId}/audit-events?limit=25`)
        .set(authHeader(actor));

      expect(response.status).toBe(expectedStatus);
    }
  );

  it("does not query audit data for a forbidden request", async () => {
    const listSpy = vi.spyOn(auditEventRepository, "listForUser");

    await request(app)
      .get("/api/users/user-2/audit-events")
      .set(authHeader({ id: "user-1", role: "member" }))
      .expect(403);

    expect(listSpy).not.toHaveBeenCalled();
  });

  it("rejects limit > 100", async () => {
    const response = await request(app)
      .get("/api/users/user-1/audit-events?limit=500")
      .set(authHeader({ id: "user-1", role: "member" }));

    expect(response.status).toBe(400);
  });
});

第二个测试很关键,它是"权限在数据查询前"这条安全纪律的机器可检查证据。它用 vi.spyOn 监听 repository 的 listForUser,断言未授权请求时这个函数根本没被调用。如果未来有人重构路由,把鉴权挪到查询之后,这个测试会立刻红——比代码审查更可靠。

测试策略上,全栈场景要覆盖三层:

  • 单元层——权限策略函数 canViewUserAudit 可单独测,纯函数无依赖,测各种 role/userId 组合;
  • 接口层——用 supertest 测整个 HTTP 链路,包括鉴权、参数校验、分页、错误响应;
  • 组件层——用 Testing Library + msw 测前端渲染,包括加载态、错误态、空态、403 态。

三层各有盲区:单测不覆盖 HTTP,接口测试不覆盖渲染,组件测试不覆盖真实后端。只有三层都绿,才算全栈闭环完成。这也是为什么任务 Prompt 里要同时跑 npm test -- user-audit-events(接口)和 npm test -- AuditEventList(组件),最后还要跑全量 npm test——后者抓跨层集成问题。

10.7.2 前端组件测试

tsx
// apps/web/src/components/AuditEventList.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { AuditEventList } from "./AuditEventList";

describe("AuditEventList", () => {
  it("renders returned audit events", async () => {
    server.use(
      http.get("/api/users/user-1/audit-events", () =>
        HttpResponse.json({
          items: [
            {
              id: "evt-1",
              actorName: "Kali",
              action: "changed email",
              createdAt: "2026-07-08T08:30:00.000Z"
            }
          ],
          nextCursor: null
        })
      )
    );

    render(<AuditEventList userId="user-1" />);

    expect(await screen.findByText("changed email")).toBeVisible();
    expect(screen.getByText("Kali")).toBeVisible();
  });

  it("renders nothing on 403", async () => {
    server.use(
      http.get("/api/users/user-2/audit-events", () =>
        new HttpResponse(null, { status: 403 })
      )
    );

    const { container } = render(<AuditEventList userId="user-2" />);

    await screen.findByText("No audit events.").catch(() => {});
    expect(container).toBeEmptyDOMElement();
  });
});

预期输出:

text
● Bash(npm test -- user-audit-events)
  ✓ admin requesting user-2 receives 200
  ✓ member requesting user-1 receives 200
  ✓ member requesting user-2 receives 403
  ✓ does not query audit data for a forbidden request
  ✓ rejects limit > 100
  5 passed

● Bash(npm test -- AuditEventList)
  ✓ renders returned audit events
  ✓ renders nothing on 403
  2 passed

验证:五条 API 测试 + 两条组件测试全绿。但此时还不能汇报完成——还要跑 lint、typecheck、全量测试(见 10.10)。

10.8 调试演示:id 类型不匹配 bug 的定位修复

这一节演示 Agent 如何在验证闭环里自我修正。故意引入一个常见 bug:数据库 audit_events.id 字段是 uuid(string),但 repository 第一轮实现里错误地把 created_at 的毫秒数当成了 id。

ts
// 错误实现(故意引入的 bug)
const items = rows.map((r) => ({
  id: r.created_at.getTime(), // number,不是 string
  actorName: r.actor_name,
  action: r.action,
  createdAt: r.created_at.toISOString()
}));

运行 typecheck,第一道防线先拦截:

text
● Bash(npm run typecheck)
apps/api/src/repositories/audit-event-repository.ts:25:5
TS2322: Type 'number' is not assignable to type 'string'.

这正是 10.5.2 节强调**显式标注返回类型 AuditEventDto[]**的原因——AuditEventDto.id 在 contracts 里是 string,而 r.created_at.getTime() 返回 number,TS 在映射处就报错,根本不用等到测试阶段。

假设 Agent 没标注类型,bug 漏到测试阶段,前端测试会这样失败:

text
● Bash(npm test -- AuditEventList)
  ✗ renders returned audit events
    TestingLibraryElementError: Found multiple elements with the text "changed email"

  console.error
    Warning: Encountered two children with the same key, `1778769000000`.

Agent 读到失败信息后,应该按这个顺序定位:

text
● Read(apps/api/src/repositories/audit-event-repository.ts)
● Grep("id:", path="apps/api/src/repositories/audit-event-repository.ts")

关键纪律:不要让 Agent 只改前端测试绕过。比如把 key={event.id} 改成 key={index} 是错误修复——它消除了告警,但掩盖了 id 重复的根因。根因在 repository 的映射,修复应该回到源头:

ts
const items: AuditEventDto[] = rows.slice(0, limit).map((r) => ({
  id: r.id, // 正确取 uuid 字段
  actorName: r.actor_name,
  action: r.action,
  createdAt: r.created_at.toISOString()
}));

如果 Agent 试图用 String(r.created_at.getTime()) 绕过类型错误,人类应立即纠偏:

text
不要用 String() 强转绕过类型错误。id 应该是数据库的 uuid 字段,
不是 created_at 的毫秒数。修复根因,读取行的真实 id 字段。

这条纠偏呼应第 06 章"根因溯源"纪律:禁止为了消除报错而简单注释或绕过,必须解决根本原因。修复后重跑:

text
● Bash(npm run typecheck)
  exit 0
● Bash(npm test -- user-audit-events)
  5 passed
● Bash(npm test -- AuditEventList)
  2 passed

这个演示说明三件事:typecheck 是第一道防线(显式标注让它发挥作用)、测试是第二道防线(抓运行时 bug)、人类反馈是第三道防线(挡住绕过式修复)。三层防御缺一不可。

10.9 增量开发:改已有代码比重新生成高效

全栈任务最容易翻车的点:Agent 倾向于"重新生成"而不是"改已有代码"。比如它可能想新建一个 AuditEventPage.tsx 而不是在现有 UserDetailPage.tsx 里加一个 section;可能想写一个新的认证中间件而不是复用 require-user

这是 LLM 的结构性倾向——它见过太多"最佳实践",倾向于应用。工程纪律要用约束压住:

  • 分步交付:先接口(可单独测)、再前端(可单独测)、再端到端测试。每步都有可验证的中间状态,而不是写完 8 个文件才第一次跑测试。本案例可以拆成三个提交:后端接口 + API 测试、前端组件 + 组件测试、端到端验证。
  • 复用现有模式:用现有 require-user 而不是写新中间件;用现有 apiClient 而不是引入新请求库;用现有 { items, nextCursor } 分页结构而不是发明新格式。复用意味着 fewer moving parts,出 bug 的面更小。
  • Edit 优先于 Write:对已存在文件用 Edit(精确替换),对新文件用 Write。Agent 应该先 Read 再决定改还是建。本案例里 UserDetailPage.tsxapp.ts 是 Edit,新路由和组件是 Write。

如果 Agent 提议"顺便重构 users 路由"或"引入 React Query",立即纠偏:

text
不要扩大范围。该任务只增加一个只读列表,沿用现有 apiClient 和 useEffect 模式。
把依赖变更和重构从计划中移除,放到后续任务。

人类反馈循环的四个典型纠偏点:

  1. 范围漂移——Agent 顺手引入新依赖,挡回去;
  2. 权限语义——Agent 把 support 角色也放行(需求只允许 admin 和本人),挡回去并要求参数化测试明确 support 返回 403;
  3. 完成定义不够——Agent 只跑目标测试就说完成,要求继续跑 lint、typecheck、全量测试;
  4. 实现可见但不可用——Agent 加了组件却忘记挂载页面,要求运行应用级验证(若项目支持 /verify,用 /verify 检查运行中的页面)。

10.10 完成标准范例与最终交付格式

任务 Prompt 里定义的"验证"和"完成输出"是闭环的最后两步。Agent 收尾时应该用下面的结构,这是"完成"的机器可检查形态:

text
Summary
- Added authorized audit-events API (GET /api/users/:userId/audit-events).
- Added typed frontend client and AuditEventList component.
- Added API authorization, pagination, and component tests.

Files
- packages/contracts/src/audit-event.ts
- packages/auth/src/can.ts
- apps/api/src/repositories/audit-event-repository.ts
- apps/api/src/routes/user-audit-events.ts
- apps/api/src/app.ts
- apps/web/src/api/audit-events.ts
- apps/web/src/components/AuditEventList.tsx
- apps/web/src/pages/UserDetailPage.tsx
- *.test.ts (新增 2 个测试文件)

Verification
- npm test -- user-audit-events: exit 0 (5 passed)
- npm test -- AuditEventList: exit 0 (2 passed)
- npm run lint: exit 0
- npm run typecheck: exit 0
- npm test: exit 0 (214 passed)

Remaining risks
- 分页 "load more" UI 未实现(当前只展示第一页)。
- 未做数据库索引变更(audit_events.user_id 假设已有索引)。
- 未覆盖 cursor 翻页的边界测试(下一任务补)。

人类最后一步永远是 git diff 看真实变更,辅以 /code-review/security-review(第 13 章)。这是人类保留的控制面,不可让渡:

text
● Bash(git diff --stat)
  apps/api/src/app.ts                                   |   3 +
  apps/api/src/repositories/audit-event-repository.ts   |  42 +++
  apps/api/src/routes/user-audit-events.ts              |  35 +++
  apps/web/src/api/audit-events.ts                      |  10 +
  apps/web/src/components/AuditEventList.tsx             |  48 +++
  apps/web/src/pages/UserDetailPage.tsx                 |   4 +
  packages/auth/src/can.ts                              |   6 +
  packages/contracts/src/audit-event.ts                 |  12 +
  8 files changed, 160 insertions(+)

git diff 看到的才算数,Claude 说"修复了"不算数。这句纪律从第 06 章贯穿到此。

10.11 失败边界

全栈场景最容易翻车的四类失败,每类都有对应对策:

边界一:一次性要求太多导致截断

任务 Prompt 里塞进"接口 + 前端 + 测试 + 重构 + 迁移 + 文档",Agent 会在中途截断——要么停在某个文件没写完,要么测试没跑就汇报完成。LLM 的输出有长度上限,任务越复杂越容易触顶。典型症状:Agent 汇报"已完成"但 git diff 显示只改了 5 个文件,后 3 个文件根本没动;或者测试文件创建了一半,只有 describe 没有 it

对策:拆任务。一个 Prompt 只覆盖一个可验证的单元。本案例如果觉得太大,可以拆成:先后端接口 + API 测试(独立可验证)、再前端组件 + 组件测试(独立可验证)、最后端到端验证。每步一个提交,出问题能快速回退。判断任务是否过大的经验法则:如果"范围"段列出的文件超过 6 个,或者"验证"段超过 5 条命令,就考虑拆。

边界二:跨文件一致性丢失

Agent 改了 contracts 的类型,但忘了更新前端的 import;或者改了路由路径,但前端 API client 还在用旧路径;或者后端返回 actor_name(下划线),前端期望 actorName(驼峰)。这类问题在 8 个文件同时变更时很常见,单测可能发现不了——单测只覆盖自己那一层,层与层之间的契约断裂要靠集成测试或 typecheck。

对策:

  • 共享 contracts 包是第一道防线——前后端引用同一个 AuditEventDto,改一处全两端都跟着变。这就是为什么本章把 DTO 放在 packages/contracts 而不是各自定义;
  • typecheck 是第二道防线——显式标注类型,让 TS 抓跨文件不一致。contracts 改了 DTO 字段,前后端引用处都会红;
  • 分步交付——每步跑一次 typecheck,缩小排查范围;
  • 最后跑全量 npm test——端到端测试抓集成问题,尤其是契约层面的字段名/字段类型不匹配。

边界三:未跑测试就认为完成

Agent 写完代码后说"功能已完成",但没跑验证命令。这是最危险的失败——你看到的"完成"是一句话,不是机器可检查的状态。生产环境炸的就是这种"完成"。

对策:把验证命令写进任务 Prompt(见 10.3 节的"验证"段),并要求 Agent 汇报每条命令的 exit code。如果 Agent 汇报"完成"但没附验证结果,立即要求:

text
继续运行 npm run lint、npm run typecheck 和 npm test。
只有全部 exit 0 才能汇报完成。汇报时附每条命令的 exit code 和输出摘要。

更彻底的做法是用 Hooks(第 14 章)在 Stop 事件上自动跑验证——没通过就不让 Agent 停。

边界四:范围漂移与过度工程

Agent 顺手引入新依赖、新抽象层、新全局状态管理。这不是恶意,是 LLM 的默认倾向——它见过太多"最佳实践",倾向于应用。本案例里典型的漂移信号:引入 React Query、引入 Zustand、新建一个 AuditEventPage.tsx 而不是改 UserDetailPage.tsx、写一个新的认证中间件。

对策:约束写在前。"不新增依赖""不改 schema""复用 apiClient"这些约束在任务 Prompt 里要显式写出来,不能假设 Agent 知道。一旦发现漂移,立即纠偏(见 10.9 节)。漂移越早挡成本越低——计划阶段挡一句"不要扩大范围"就行,代码写完再挡要回退一堆文件。


本章是把第 06 章的可执行闭环、第 07 章的 Plan Mode、第 08 章的精准指令、第 09 章的重构与 Bug 修复组装起来的综合演练。核心心法仍是那一句:你做架构决策和关键审查,Claude 做实现和探索。全栈场景只是把这条心法放大到更多文件、更长闭环——人定方向(任务 Prompt 的目标与约束),机器填细节(路由、组件、测试),人做终审(git diff + /code-review)。下一章进入上下文管理进阶,讲怎么在长闭环里对抗上下文衰减。


上一章:遗留代码重构与 Bug 修复
下一章:上下文管理进阶