从前端开发到 AI Agent:我的 Agent 工程化学习与实践
用于记录和实践 AI Agent 工程化、工具调用、上下文管理与应用开发的实验项目。
从前端运行时进入 AI Agent 后端
真正开始学习 AI Agent 开发之后,我遇到的第一个问题并不是 Prompt,也不是 RAG,更不是 Agent 框架,而是后端。
作为长期从事前端开发的人,我对 JavaScript、TypeScript、Promise、HTTP 和 Event Loop 并不陌生,但过去大多数代码都运行在浏览器中。
而一个真正的 Agent 系统通常需要完成:
- 调用模型 API
- 保存 Agent Task
- 管理用户会话
- 调用数据库
- 执行 Tool
- 读取代码仓库
- 运行测试
- 保存 Trace
- 管理 API Key
- 执行高风险操作审批
这些能力决定了 Agent 的核心运行环境必须放在服务端。
因此我的 AI Agent 学习并没有直接从某个 Agent Framework 开始,而是先补齐 Node.js 服务端运行模型。
浏览器与 Node.js 最大的差异不是 API,而是生命周期
前端 Vue 应用通常跟随浏览器页面生命周期存在:
打开页面
↓
加载 JavaScript
↓
创建 Vue Application
↓
处理用户交互
↓
关闭页面
↓
运行环境销毁
Node.js 服务却完全不同:
启动 Node Process
↓
加载配置
↓
创建 Application
↓
监听 HTTP Port
↓
持续处理不同用户请求
↓
直到进程关闭或重新部署
这意味着一个非常重要的变化:
Node 服务不是每个请求重新执行一次程序,而是一个长期运行的进程持续处理大量请求。
例如:
let requestCount = 0;
app.get('/count', async () => {
requestCount++;
return {
requestCount,
};
});
连续访问接口时会得到:
1
2
3
...
requestCount 并不会因为一次请求结束而自动消失。
这个实验看起来非常简单,却直接影响后面 Agent State 的设计。
如果错误地把:
let currentUserId: string;
放到共享模块状态中,不同用户请求之间就可能互相覆盖。
因此以后设计 Agent 系统时必须持续区分:
Process State
Request State
User State
Session State
Agent Task State
Persistent State
这也是服务端开发和前端组件状态管理之间一个很重要的认知迁移。
为什么 API Key 必须留在服务端
在 Vite 项目里:
import.meta.env.VITE_OPENAI_API_KEY;
即使值最初来自 .env,只要它进入客户端构建代码,就不能被视为 Secret。
所以正确的 Agent 调用结构应该是:
Vue
↓
自己的 API
↓
Fastify
↓
LLM Provider
浏览器并不直接获得模型 API Key。
这个结构未来还会继续承担:
Authentication
Authorization
Rate Limit
Tool Permission
Cost Control
Audit
Agent State
因此 Fastify API 并不是简单的“模型 API 代理层”,而会逐渐成为整个 Agent Runtime 的重要边界。
使用 Zod 在程序启动阶段验证配置
服务端出现了一个前端开发中容易忽视的问题:
process.env.PORT;
它的类型并不是:
number;
而是:
string | undefined;
即使配置文件里写的是:
PORT=3000
Node 收到的依然是字符串 "3000"。
于是我开始使用 Zod 给应用配置建立 Runtime Schema:
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
HOST: z.string().trim().min(1).default('127.0.0.1'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});
这里真正重要的并不是 Zod 的 API,而是一个原则:
TypeScript 只能约束编译时期由我们控制的代码,无法保证运行时进入系统的数据一定符合类型。
环境变量如此。
后面还会遇到:
HTTP Request
LLM Structured Output
Tool Arguments
MCP Input
External API
Database Import
这些数据都属于外部输入,都必须重新进行 Runtime Validation。
因此 Zod 后面不仅会出现在 HTTP API 中,还会贯穿整个 Agent 系统。
Fail Fast:配置错误时不要启动服务
对于错误配置:
PORT=abc
与其让服务先启动,等用户请求真正进入系统后再失败,不如在程序初始化阶段就阻止服务运行。
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error(z.prettifyError(parsed.error));
process.exit(1);
}
这里引出了另一个服务端概念:Process Exit Code。
0
→ 程序成功退出
非 0
→ 程序异常退出
这个值不仅是 Node 自己使用。
它还会被:
Shell
Docker
CI/CD
Process Manager
Cloud Platform
读取。
所以一个后端程序最终不仅要“能运行”,还需要正确地向运行环境表达自己的状态。
Graceful Shutdown:服务不是直接关掉就结束了
本地开发时按下:
Ctrl + C
通常会触发:
SIGINT
而 Docker、云平台或 Linux 服务停止时通常会发送:
SIGTERM
如果程序收到信号后立即消失,正在执行的:
HTTP Request
Database Transaction
Agent State Write
Trace Export
File Operation
都有可能被直接中断。
所以服务需要一个 Graceful Shutdown:
async function shutdown() {
await app.close();
}
完整思路是:
收到关闭信号
↓
停止接收新请求
↓
处理已有请求
↓
释放数据库等资源
↓
完成 Trace / Log
↓
退出 Node Process
这个知识点后面进入 Agent Task Persistence 时会变得更加重要。
一个可能执行几十秒甚至几分钟的 Agent,不应该因为一次服务器更新就无条件丢失整个执行状态。
为什么当前没有使用 tsup
前端工程经验让我一开始很自然地想到:
TypeScript 项目是不是应该再用一个 Bundler?
但目前的 Fastify API 并不需要。
现在的执行链:
TypeScript
↓
tsc
↓
ESM JavaScript
↓
Node.js
已经完整。
浏览器需要 Bundling 的一个重要原因是资源需要跨网络加载,而 Node.js 中:
import './app.js';
是服务器本地模块加载。
所以:
100 个 Node Module
并不等于:
浏览器产生 100 个 HTTP Request
当前使用 Bundler 反而会引入:
entry
external
bundle
splitting
format
等额外复杂度。
因此目前:
Fastify Application
→ tsc
未来需要 npm Library / CLI Distribution
→ 再评估 tsdown 等 Bundler
这是我在这个项目中逐渐形成的一条工程原则:
构建工具应该解决真实存在的问题,而不是因为“现代项目通常这么做”就默认引入。
Fastify Route:HTTP 世界与业务世界的边界
建立最小服务之后,第二步开始理解 Fastify Route。
一个 HTTP Request:
POST /projects/p-001/tasks?notify=true
Authorization: Bearer xxx
{
"title": "Analyze repository"
}
进入 Fastify 后,可以拆成:
Params
→ p-001
Query
→ notify=true
Headers
→ Authorization
Body
→ title
对应:
request.params;
request.query;
request.headers;
request.body;
Fastify Route Generic 可以为这些属性提供 TypeScript 类型:
app.post<{
Params: Params;
Querystring: Query;
Body: Body;
Headers: Headers;
}>('/projects/:projectId/tasks', handler);
但这里出现了一个非常重要的陷阱。
假设:
interface CreateTaskBody {
title: string;
}
即使我告诉 TypeScript:
Body: CreateTaskBody;
客户端依然完全可以发送:
{
"title": 123
}
因为 TypeScript 类型在代码编译之后已经不存在。
所以:
Route Generic
→ 给开发者提供 Compile-time Type
Runtime Schema
→ 验证真实 HTTP Input
两者解决的是完全不同的问题。
这也自然引出了下一阶段:
如何让 Fastify 的 HTTP Runtime Validation、Zod Schema 和 TypeScript 类型真正统一起来。
Route / Service:先拆开 HTTP 与业务逻辑
随着 Task API 开始出现,我也开始第一次真正处理后端代码的职责边界。
如果把所有逻辑都直接写在 Route Handler 中,很容易得到这样的代码:
app.post('/tasks', async (request, reply) => {
// 参数校验
// 业务规则
// 数据查询
// 调用模型
// 执行 Tool
// 记录 Trace
// 构造 Response
});
现在接口很简单时看不出问题,但未来 FrontOps Agent 会逐渐增加:
Repository Analysis
Issue Analysis
LLM Calling
Tool Execution
Agent State
Persistence
Tracing
Approval
如果这些能力全部继续进入 Handler,HTTP 层和 Agent 业务逻辑会迅速耦合。
因此目前我先建立最小的职责划分:
Route
↓
Service
Route 负责 HTTP 世界:
Method
URL
Params
Query
Headers
Request Body
Status Code
Response
Service 则负责 Application Logic。
例如:
export function previewTask(input: TaskPreviewInput): TaskPreviewResult {
return {
repository: input.repository,
question: input.question,
mode: input.mode,
status: 'ready',
};
}
Service 不接受:
FastifyRequest;
FastifyReply;
也不关心:
HTTP 200
HTTP Header
Cookie
这样做不是为了让目录看起来更加“企业级”,而是为了避免业务逻辑被 HTTP Framework 绑死。
以后同一段 Service 完全可能被:
HTTP API
CLI
Background Worker
Agent Worker
共同调用。
至于:
Repository
目前项目还没有真正进入 Persistence,所以暂时没有创建这一层。
后面开始使用 PostgreSQL 后,再根据真实的数据访问需求把:
Route
↓
Service
↓
Repository
完整建立起来。
这也让我进一步确认了一条工程原则:
abstraction 应该由真实需求推动,而不是为了提前得到一套“标准目录结构”而创建。
从手写 safeParse() 到 Fastify Validation Pipeline
最开始处理 HTTP Body 时,我在 Handler 中显式执行:
const parsed = taskPreviewInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
code: 'INVALID_REQUEST',
});
}
const result = previewTask(parsed.data);
这个版本虽然存在重复,但非常适合作为第一阶段实现。
因为整个 Runtime Boundary 是完全可见的:
request.body
↓
untrusted runtime data
↓
Zod safeParse()
↓
validated input
↓
Service
通过几个故意构造的请求,我分别验证了:
{
"repository": 123
}
会因为类型不正确被拒绝;
{
"mode": "agent"
}
会因为不属于允许的 enum 被拒绝;
以及:
{
"repository": "frontend",
"question": "Analyze",
"mode": "architecture",
"admin": true
}
会因为 Schema 使用了 .strict() 而拒绝未声明字段。
这些实验让我第一次真正从 Runtime 层面确认:
HTTP Contract 不是 TypeScript interface,而是运行时真正执行的 Schema。
不过这个实现也暴露出了明显的问题。
如果未来每一个 Route 都需要:
schema.safeParse(request.body);
那么应用会逐渐出现大量重复的 Boundary Code。
而 Fastify 本身已经拥有完整的 Validation Lifecycle。
真正更合理的执行方式应该是:
HTTP Request
↓
Body Parsing
↓
Runtime Validation
↓
Handler
↓
Service
也就是说:
Validation 应该成为进入 Handler 的前置条件,而不是 Handler 自己记得执行的一项任务。
因此我把 Zod 进一步接入 Fastify Validation Pipeline。
一个 Zod Schema,同时服务 Runtime 和 TypeScript
完成集成以后,Route 可以直接声明:
server.post(
'/preview',
{
schema: {
body: taskPreviewInputSchema,
},
},
async (request, reply) => {
const result = previewTask(request.body);
return reply.status(200).send(result);
},
);
原来 Handler 中的:
safeParse();
消失了。
但 Runtime Validation 并没有消失。
它只是从:
Handler
移动到了:
Fastify Validation Pipeline
现在一次请求的实际过程变成:
HTTP Request
↓
Fastify Parsing
↓
validatorCompiler
↓
Zod Schema
│
├── invalid → 400
│
└── valid
↓
Handler
↓
Service
与此同时,TypeScript 还有另一条完全不同的链路:
Zod Schema
↓
ZodTypeProvider
↓
TypeScript inference
↓
request.body
例如 Schema:
const taskPreviewInputSchema = z.object({
repository: z.string(),
question: z.string(),
mode: z.enum(['architecture', 'issue', 'code']),
});
可以让:
request.body.mode;
自动得到:
'architecture' | 'issue' | 'code';
这里有一个很容易混淆、但必须明确区分的概念:
validatorCompiler
→ Request Runtime Validation
serializerCompiler
→ Response Runtime Serialization
ZodTypeProvider
→ Compile-time Type Inference
前两个属于 Runtime。
Type Provider 属于 TypeScript 编译阶段。
它们共同使用 Schema,但解决的是不同的问题。
因此现在的结构可以抽象成:
Zod Schema
/ \
/ \
▼ ▼
Runtime Compile Time
│ │
validatorCompiler ZodTypeProvider
serializerCompiler │
│ ▼
│ Type Inference
▼
Fastify Runtime
这也是我目前第一次真正体会到 Schema-driven API 的价值:
Contract 不再只是给开发者看的 TypeScript 类型,而可以同时参与 Runtime Validation、Serialization 和 Static Type Inference。
Structural Validation 不是 Business Validation
Zod 可以证明:
repository: z.string();
意味着真实 Runtime Input 中:
repository
确实是一个字符串。
但它无法证明:
这个 Repository 真的存在
更无法证明:
当前用户有权访问这个 Repository
例如:
{
"repository": "repository-that-does-not-exist",
"question": "Analyze",
"mode": "architecture"
}
完全可以通过 Zod。
所以目前我开始明确区分三个不同层次:
Structural Validation
↓
数据结构是否合法?
Business Validation
↓
业务事实是否成立?
Authorization
↓
当前调用者是否有权执行?
例如:
mode 必须是 architecture | issue | code
→ Structural Validation
repository 必须真实存在
→ Business Validation
当前用户可以访问 repository
→ Authorization
这件事情以后进入 Agent Tool Calling 时会变得更加重要。
因为模型生成:
{
"path": "../../etc/passwd"
}
完全可以满足:
path: z.string();
但:
这个 path 是否允许 Tool 访问?
属于另外一层 Security / Permission Boundary。
因此:
Runtime Schema Validation 是系统建立信任的第一层,但绝对不是最后一层。
Error 也是 API Contract 的一部分
把 Validation 移到 Fastify Pipeline 后,我马上遇到了新的问题。
以前 Handler 自己执行:
safeParse();
时,可以完全控制错误响应:
{
"code": "INVALID_REQUEST",
"message": "Request body is invalid"
}
Validation 移到 Framework Pipeline 后,非法请求会在 Handler 执行以前失败。
于是错误开始由 Fastify 默认 Error Handler 返回。
这意味着虽然 Validation 的职责位置更加合理,但:
API Error Contract 开始被 Framework 默认行为控制。
因此现在第一次有了真实理由加入统一 Error Handler。
目前 API 已经能够区分:
INVALID_REQUEST
→ Zod Structural Validation Error
REQUEST_ERROR
→ HTTP / Fastify Request Error
NOT_FOUND
→ Route 没有匹配
INTERNAL_SERVER_ERROR
→ Unexpected Server Error
这里最重要的并不是 Error Code 本身,而是开始建立:
Internal Error
↓
Error Mapping
↓
Public API Error Contract
的意识。
例如 Fastify 自己可能产生:
FST_ERR_...
这些 Framework Error Code 更适合进入:
Server Log
Trace
Debugging
而不是直接成为前端长期依赖的 Public API Contract。
否则:
Frontend
↓
依赖 Fastify Error Code
就会让外部协议和底层 Framework 强耦合。
Framework Error 不等于 Server Error
这个过程中还有一个很重要的错误分类问题。
最简单的 Error Handler 很容易写成:
if (isValidationError(error)) {
return reply.status(400).send(...);
}
return reply.status(500).send(...);
但:
不是 Zod Error,并不代表就是 Server Error。
例如一个 malformed JSON:
{
"repository":
}
请求甚至不会进入 Zod Validation。
它会更早失败:
HTTP Request
↓
Body Parsing
↓
JSON Parsing ❌
这是一个客户端发送了非法 HTTP Payload 的问题,本质应该属于 4xx。
如果统一转换成:
500 Internal Server Error
反而破坏了 HTTP 语义。
所以现在 Error Handler 至少需要能够区分:
Structural Validation Error
HTTP / Framework Client Error
Unexpected Server Error
这也是我第一次真正开始从:
“发生错误就 catch”
转向:
“这个错误属于系统中的哪一个 Boundary?”
404 甚至不经过普通 Error Handler
继续测试:
GET /does-not-exist
又暴露了另一个 Fastify Lifecycle 细节。
Router 没有找到 Route 时产生的 404,并不会像普通 Error 一样经过统一的 setErrorHandler()。
它需要单独的:
setNotFoundHandler();
于是 Request Pipeline 可以进一步展开:
Incoming Request
│
▼
Router
│
┌───┴─────┐
│ │
matched not found
│ │
▼ ▼
Parsing NOT_FOUND
│
▼
Validation
│
▼
Handler
这让我意识到:
Error 的来源阶段不同,并不意味着最终都一定经过同一个 Error Hook。
以后 Agent Runtime 也会存在类似差异:
Route Not Found
Task Not Found
Tool Not Found
Repository Not Found
表面上都叫 “Not Found”,但它们分别属于 HTTP Infrastructure、Application State、Tool Registry 和业务数据层。
Response 也需要自己的 Boundary
完成 Request Validation 后,还有一个容易忽略的问题:
Service 返回的数据,就应该完整发送给 Client 吗?
不一定。
假设以后 Agent Service 返回:
{
answer: '...',
taskId: 'task-001',
internalTraceId: 'trace-001',
rawModelResponse: ...,
tokenUsage: ...,
internalPrompt: ...,
}
这些数据可能都对 Service 内部有用。
但 Client 真正需要的可能只有:
{
"answer": "...",
"taskId": "task-001"
}
因此:
Service Result
≠
Public API Response
我开始为成功 Response 同样定义 Zod Schema:
const taskPreviewResponseSchema = z.object({
repository: z.string(),
question: z.string(),
mode: z.enum(['architecture', 'issue', 'code']),
status: z.literal('ready'),
});
然后 Route:
server.post(
'/preview',
{
schema: {
body: taskPreviewInputSchema,
response: {
200: taskPreviewResponseSchema,
},
},
},
async (request, reply) => {
const result = previewTask(request.body);
return reply.status(200).send(result);
},
);
于是 HTTP 两侧开始形成对称结构:
Request
↓
Validation
↓
Application
Application
↓
Serialization
↓
Response
serializerCompiler 也终于有了明确职责:
Service Result
↓
Response Schema
↓
serializerCompiler
↓
Serialized HTTP Response
Response Schema 也是安全边界
为了验证 Response Boundary,我做了一个简单实验:
让 Service 临时多返回:
internalNote: 'do not expose this';
但 Response Schema 并没有声明:
internalNote
最终 HTTP Response 不应该因为 Service 内部对象出现了额外字段,就自动扩大 Public API Contract。
这个实验让我重新理解 Response Schema 的价值。
它不只是:
“让返回 JSON 格式正确。”
同时也是:
Data Minimization
API Contract
Sensitive Data Leakage Prevention
以后 FrontOps Agent 内部很可能持有:
System Prompt
Raw Model Response
Tool Context
Trace
Filesystem Information
Provider Metadata
Token Usage
Secret-related Context
这些都不能因为它们存在于某个 JavaScript Object 中,就默认允许离开 Server Boundary。
所以:
输入需要建立信任,输出同样需要控制暴露范围。
一次真实的 Request Lifecycle 调试
这一阶段还遇到了一个很有代表性的调试问题。
最开始使用 inline curl 发送包含中文的 JSON 时,Fastify 返回:
FST_ERR_CTP_INVALID_CONTENT_LENGTH
错误信息表示:
Content-Length
≠
实际读取到的 Body bytes
一开始很容易把注意力放到:
Zod Schema
Route
Service
但这个 Error 实际发生的位置更早:
curl
↓
HTTP
↓
Fastify Body Parsing ❌
↓
Zod
↓
Handler
通过控制变量继续测试:
payload 文件 + 中文
→ 成功
inline ASCII
→ 成功
inline 中文
→ 失败
最终可以把问题范围收缩到:
Shell / command-line argument
→ curl
这一层的 Unicode 数据传递,而不是 Fastify Application。
这个问题本身没有继续深入的必要,但调试过程值得保留。
它让我真正体会到:
出现一个
400时,不应该马上去检查业务 Validation,而应该先判断 Error 发生在 Request Lifecycle 的哪一个阶段。
以后 Agent 系统也会出现完全相同的问题。
例如一次失败可能来自:
HTTP Parsing
Request Validation
Authentication
Authorization
LLM
Structured Output
Tool Validation
Tool Execution
Persistence
最终虽然都可能表现为:
Request Failed
但调试入口完全不同。
从 HTTP Boundary 提前看到 Agent Boundary
目前整个 HTTP Pipeline 已经可以抽象为:
External Input
↓
Structural Validation
↓
Trusted Application Input
↓
Application Logic
↓
Internal Result
↓
Response Contract
↓
External Output
这套模型其实已经开始直接映射未来的 Agent Runtime。
HTTP 中:
Request Body
↓
Zod
↓
Service
以后 Tool Calling:
Model-generated Tool Arguments
↓
Zod
↓
Permission / Sandbox
↓
Tool Executor
因此现在可以提前得到一个非常重要的 Agent Engineering 原则:
模型生成的数据和用户发送的 HTTP 数据一样,都属于 Runtime External Input。
即使后面使用 Structured Outputs,也只是增加了模型输出的结构约束。
真正进入 Tool 执行之前,仍然需要继续考虑:
Runtime Validation
Authorization
Permission
Sandbox
Timeout
Approval
也就是说,今天建立的 HTTP Boundary 并不是一个独立的 Fastify 知识点。
它会直接成为后面 Tool Calling、MCP 和 Agent Security 的基础。
当前阶段得到的核心结论
经过 Node.js、Fastify 和 Zod 这一阶段,我对 AI Agent 开发的理解又进一步发生了变化。
最开始很容易把 Agent 理解成:
Prompt
+
LLM
+
几个 Tool
但真正开始搭建运行环境以后,更接近:
Frontend
+
Backend Runtime
+
Boundary Contract
+
Validation
+
State
+
LLM
+
Tool
+
Persistence
+
Security
+
Observability
+
Eval
目前虽然还没有真正接入模型,但已经建立了几个之后会持续复用的工程原则。
第一:
TypeScript 类型只能保护我们控制的代码,任何来自 Runtime Boundary 的数据都需要重新验证。
第二:
Structural Validation、Business Validation 和 Authorization 是三个不同层次的问题。
第三:
Route 面向 HTTP Transport,Service 面向 Application Logic,两者不应该因为当前使用 Fastify 就完全耦合。
第四:
Request 和 Response 都是 Boundary。输入需要验证,输出需要明确控制公开 Contract。
第五:
Error 同样属于 API Contract。内部 Error、Framework Error 与 Public Error Code 不应该混成一套概念。
现在的 HTTP Boundary 已经可以画成:
Client
│
▼
Routing
│
┌────────┴────────┐
│ │
matched NOT_FOUND
│
▼
Parsing
│
├── error → REQUEST_ERROR
▼
Structural Validation
│
├── error → INVALID_REQUEST
▼
Handler
│
▼
Service
│
▼
Response Schema
│
▼
Serialization
│
▼
Client
下一步开始进入另一个只返回普通 JSON 很难解决的问题:
如果一次 LLM / Agent 执行需要持续几秒甚至几十秒,服务是否应该一直等到所有工作完成后,才一次性返回 Response?
这会继续把当前的 HTTP Request / Response 模型推进到:
Streaming
SSE
Connection Lifecycle
Abort
Cancellation
也会第一次开始为真正接入 LLM API 做准备。
Route 不应该承担所有错误处理
Route 容易同时承担:
- HTTP 参数处理
- 调用外部服务
- 业务判断
- 错误转换
- Response Format
这些职责变化原因不同。
更合理的边界:
Route
负责 HTTP Boundary
↓
Service
负责 Application Logic
Service 不应该直接依赖 FastifyReply。
Application Error 与 HTTP Error 分离
Business Error 不等于 HTTP Error。
例如:
REPOSITORY_NOT_FOUND
表示 Application 层的业务语义。
经过 HTTP Boundary 后:
404 Not Found
才是 HTTP Client 理解的协议表达。
因此:
Application Error
↓
HTTP Mapping
↓
HTTP Response
而不是:
Service
↓
reply.status(404)
AppError 与 Error Code
错误判断不应该依赖 message:
error.message === 'Repository not found';
因为文本容易变化。
应该使用稳定 Error Code:
REPOSITORY_NOT_FOUND
而 message 用于诊断信息。
Fastify Error Pipeline
Fastify 通过 setErrorHandler 建立统一错误处理边界:
Request
↓
Validation
↓
Route
↓
Service
↓
Error
↓
Error Handler
↓
HTTP Response
这样 Route 不需要重复编写错误处理逻辑。
Error 分类
当前阶段主要区分:
Validation Error
外部输入不符合 Contract。
例如:
repository id 格式错误
通常返回:
400
Application Error
业务逻辑可以明确判断。
例如:
Repository 不存在
映射:
REPOSITORY_NOT_FOUND
↓
404
Unknown Error
系统无法理解业务语义的异常。
不能强行转换为业务错误。
正确方式:
记录内部错误
↓
返回安全公共错误
使用 app.inject 测试 Error Contract
为了验证完整 Fastify 生命周期,不直接调用 errorHandler。
使用:
app.inject();
测试:
Vitest
↓
Fastify Instance
↓
Request Lifecycle
↓
Error Handler
↓
Response Contract
验证的是最终行为,而不是单个函数。
buildApp 与 listen 分离
应用构建和服务器启动应该分离:
app.ts
负责 Application
↓
server.ts
负责 listen()
这样测试可以创建 Fastify Instance,而不需要真实监听端口。
把 FrontOps Agent 的数据从内存搬进 PostgreSQL
我已经完成了 Fastify、Zod、Route / Service / Repository 分层以及测试体系的基础搭建。
问题第一次从:
“Backend 能不能接住一个请求”
变成:
“这些数据到底应该如何被可靠地持久化”
这也是我从前端工程思维切换到后端工程思维时,一个非常明显的节点。
对于前端开发来说,很多状态天然存在于:
Component State
Pinia / Vuex
Browser Storage
Remote API Cache
但进入后端以后,真正需要长期保存、支持并发访问、保证一致性的数据,必须交给数据库。
这一次我正式把 FrontOps Agent 的项目数据接入 PostgreSQL。
1. 为什么不能一直使用内存数据
最开始为了验证 Route / Service / Repository 的调用链,Repository 完全可以使用内存数组:
const projects: Project[] = [];
这种实现的价值是:
快速验证架构
+
没有数据库依赖
+
测试简单
但它无法承担真实系统的数据存储职责。
只要 Node.js Process 重启:
Process Exit
↓
Memory Lost
↓
Projects Lost
同时,多进程、多实例部署以后,每个实例都有自己的内存:
API Instance A
→ projects A
API Instance B
→ projects B
两份状态彼此不一致。
因此 PostgreSQL 在 FrontOps Agent 中承担的是:
Persistent Source of Truth。
这和 Vue State 完全不是同一个角色。
可以这样映射:
Vue State
→ 当前 Client Runtime State
PostgreSQL
→ Server Persistent State
2. PostgreSQL Client 和 Connection Pool
第一次接 PostgreSQL 时,一个很容易写出的实现是:
const client = new Client({
connectionString,
})
await client.connect()
const result = await client.query(...)
这在一次性脚本中完全合理。
但对于长期运行的 API Server:
Request
Request
Request
Request
...
数据库连接本身是一种有限资源。
如果每个 Request:
new Client()
→ connect()
→ query()
→ close()
不仅增加连接建立成本,也容易在高并发下耗尽数据库的连接资源。
因此 Web Server 更常见的方式是:
const pool = new Pool(...)
然后:
Application Startup
↓
Create Pool
↓
Request 1
Request 2
Request 3
↓
Reuse Connections
这里我第一次把数据库连接池和以前理解 TCP / Browser Connection Reuse 的经验建立了映射:
连接不是无限资源,没有必要每个操作重新创建。
3. Pool、Client、release()、end() 不是同一个生命周期
我特别容易混淆的是:
pool.query(...)
pool.connect()
client.release()
pool.end()
它们看起来都在操作数据库连接,但语义不同。
pool.query()
适合:
单条独立 Query。
例如:
await pool.query('SELECT * FROM projects WHERE id = $1', [id]);
Pool 会自动帮我:
获取 Client
→ 执行 Query
→ 归还 Client
因此调用方不需要手动 release()。
pool.connect()
当多个 SQL 操作必须使用 同一个 Connection 时,需要显式拿到 Client:
const client = await pool.connect();
try {
// ...
} finally {
client.release();
}
典型场景是 Transaction:
BEGIN
↓
Query A
↓
Query B
↓
COMMIT / ROLLBACK
这些操作不能分别随机落到 Pool 中不同 Client 上。
因此:
pool.query()
→ 独立 Query
pool.connect()
→ 显式获取同一 Client
是两个不同使用场景。
client.release()
release() 表示:
当前代码不再占用这个 Client,把连接归还 Pool。
它不是:
关闭整个数据库系统
而是:
Borrowed Connection
↓
release()
↓
Return To Pool
pool.end()
pool.end() 的语义则完全不同:
关闭整个 Pool,并结束数据库连接资源。
所以一般发生在:
Application Shutdown
Test Teardown
Script Completion
而不是每个 Request 执行之后。
这套生命周期管理让我意识到:
Backend Resource Lifecycle 必须明确区分 Application Scope、Request Scope 和 Operation Scope。
这个原则后面同样会继续出现在 LLM Client、Agent Task、Tool Execution 等系统里。
4. 为什么 Repository 应该负责 SQL
数据库接入以后,我仍然保持:
Route
→ Service
→ Repository
Repository 开始真正承担:
SQL
Database Mapping
Persistence
例如:
export class ProjectRepository {
constructor(private readonly pool: Pool) {}
async create(input: CreateProjectInput) {
const result = await this.pool.query(
`
INSERT INTO projects (
name,
repository_url
)
VALUES ($1, $2)
RETURNING
id,
name,
repository_url,
created_at
`,
[input.name, input.repositoryUrl],
);
return result.rows[0];
}
}
我不希望 Route 里出现:
await pool.query(...)
也不希望 Service 到处直接写 SQL。
因为:
Route
→ HTTP Boundary
Service
→ Business Logic
Repository
→ Persistence Boundary
这三个职责仍然需要分开。
但这里也不能机械分层。
如果一个 Feature 当前只有非常简单的数据操作,不应该为了“企业架构感”继续创建:
DAO
Mapper
Gateway
StorageService
RepositoryImpl
每一个 abstraction 都必须解决一个真实问题。
5. SQL 参数必须参数化
数据库接入之后,第一个非常重要的安全习惯就是:
不要这样:
const sql = `
SELECT *
FROM projects
WHERE name = '${name}'
`;
而应该:
await pool.query(
`
SELECT *
FROM projects
WHERE name = $1
`,
[name],
);
参数化 Query 的核心价值不是“代码更整洁”,而是:
把 SQL Structure 和 User Data 分开。
应用传入的数据不会直接成为 SQL 语句结构的一部分。
这一点和前端时代常见的:
HTML String Concatenation
vs
Framework Escaping / Binding
有类似的安全思维:
不要让外部数据直接进入可执行结构。
6. 数据库 Schema 是 Runtime Contract 的另一层
第一周前面已经建立:
HTTP Input
↓
Zod Validation
接入 PostgreSQL 后又多了一层:
Application Data
↓
Database Constraints
例如:
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
repository_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
这里的:
PRIMARY KEY
NOT NULL
DEFAULT
并不是可以被 TypeScript 替代的。
即使代码里有:
type Project = {
id: number;
name: string;
};
数据库本身仍然必须保证:
id 唯一
name 不能为空
因为数据库不只会被某一个 TypeScript function 使用。
未来可能还有:
Migration
Background Job
Admin Script
Other Service
Manual SQL
都会直接操作它。
因此:
Database Constraint 是 Data Source 自己的最后一道 Runtime Contract。
7. 为什么 PRIMARY KEY 应该由数据库保证
我确认了一个非常重要的原则:
Entity Identity
不能只依赖 Application “自己记得不重复”。
例如:
id BIGSERIAL PRIMARY KEY
数据库可以保证:
唯一性
+
非空
+
索引语义
如果只是 Application 里:
const id = projects.length + 1;
那在并发、多实例环境里根本无法可靠成立。
因此这种全局数据一致性约束:
应该尽可能靠近真正的数据源。
8. created_at 为什么由数据库生成
created_at 这种字段同样适合:
created_at TIMESTAMPTZ
NOT NULL
DEFAULT NOW()
而不是每个调用方都:
createdAt: new Date();
原因是:
created_at
描述的是:
这一行数据进入数据库时的创建时间。
让数据库负责:
统一
可靠
避免调用方遗漏
更加合理。
Application 不需要每次创建项目都记住:
还要生成 createdAt
9. 为什么选择 TIMESTAMPTZ
对于 Backend 持久化时间,我使用:
TIMESTAMPTZ
而不是只保存一个没有时区语义的 timestamp。
工程上的考虑不是:
“数据库里显示哪个时区更漂亮。”
而是希望保存的是一个明确时间点。
不同使用者最终可以根据:
UTC
User Locale
Server Locale
转换展示。
这和前端国际化中的时间处理很接近:
Persist absolute instant
↓
Presentation layer
↓
Local timezone formatting
不要把最终展示时区和底层持久化语义混在一起。
10. 数据库命名和 TypeScript 命名不必强行统一
数据库常见:
snake_case
TypeScript 常见:
camelCase
例如:
repository_url
created_at
而 Application 使用:
repositoryUrl;
createdAt;
这两个世界不需要为了所谓统一而强迫其中一边改变。
Repository Boundary 正好可以负责转换:
Database Row
repository_url
created_at
↓
Repository Mapping
↓
Application Object
repositoryUrl
createdAt
这也是 Repository 存在的另一个实际价值:
隔离 persistence representation 和 application representation。
11. UNIQUE 不是所有字段默认都应该加
学习表结构设计时,一个很容易出现的倾向是:
既然唯一性很好,
那 name、URL 都加 UNIQUE。
但 UNIQUE 实际是业务规则。
例如:
repository_url TEXT UNIQUE
到底对不对,要先回答:
一个 Repository 是否只允许创建一个 Project?
是否允许不同 Workspace 导入同一个 Repository?
同一 Repo 是否可能有不同 Branch / Configuration?
如果业务规则没有确定,就不能因为“看起来应该唯一”直接写约束。
所以:
Database Constraint 必须有业务语义依据。
PRIMARY KEY 是 Entity Identity 的基础约束。
而普通字段上的:
UNIQUE
CHECK
FOREIGN KEY
则需要结合具体业务规则设计。
12. Application Validation 和 Database Validation 为什么都需要
这是一个非常重要的边界问题。
例如:
Project Name 不能为空
Application 可以:
z.string().trim().min(1);
数据库也可以:
name TEXT NOT NULL
这不是重复浪费。
它们服务的是不同边界。
Application Validation
解决:
HTTP Client
↓
Invalid Input
↓
尽早给出明确业务错误
目标是:
开发体验
API Contract
业务语义
Database Constraint
解决:
任何数据库写入路径
↓
最终数据一致性
目标是:
Persistence Integrity
所以:
Application Validation
+
Database Validation
不是二选一。
13. Repository Test 和真实数据库测试的边界
接入 PostgreSQL 后,我也开始重新理解测试分层。
Service Unit Test 不应该因为 Repository 换成 PostgreSQL 就突然需要数据库。
例如:
ProjectService
↓
Fake ProjectRepository
仍然应该能够快速、deterministic 地测试业务逻辑。
而真正 SQL 是否正确:
INSERT
SELECT
RETURNING
column mapping
constraint
则需要更接近数据库的 integration test。
因此:
Service Unit Test
→ Fake Repository
Repository / Integration Test
→ Real PostgreSQL
它们证明的是不同问题。
这和后面 LLM Testing 的:
Service Unit Test
→ Fake LLM
Provider Smoke Test
→ Real Model
其实是同一种 Testing Boundary 思维。
14. 为什么测试结束必须释放资源
数据库测试还有一个前端时代不太常见的问题:
Open Handle
如果测试创建了 Pool,却没有:
await pool.end();
Vitest 可能执行完 assertion 之后仍然无法正常退出。
原因不是 Vitest 出问题,而是 Node.js Event Loop 中仍然存在活跃资源。
因此测试需要明确:
afterAll(async () => {
await pool.end();
});
或者按照实际测试生命周期使用:
afterEach(...)
这个问题让我进一步理解:
Backend Test 不只验证返回值,还必须管理真实 Resource Lifecycle。
15. FrontOps Agent 为什么需要 PostgreSQL
目前 PostgreSQL 只保存 Project 这类基础数据。
但 FrontOps Agent 最终还会需要持久化:
Projects
Imported Repositories
Agent Tasks
Task Status
Human Approval State
Tool Timeline
Trace Metadata
Eval Results
Cost / Token / Latency Data
因此 PostgreSQL 并不是“为了学 Backend 顺手加一个数据库”。
它最终会成为整个 Agent Platform 的持久状态基础。
后续还会继续扩展:
PostgreSQL
↓
pgvector
↓
Vector Retrieval
但当前阶段还没有必要提前学习这些内容。
16. 从前端状态管理映射到后端持久化
对我最有帮助的一个映射是:
Vue State
≠
Server State
≠
Persistent State
前端:
ref()
reactive()
Pinia
解决的是当前 Client Runtime 中:
UI 如何随着状态变化
而 PostgreSQL 解决的是:
数据如何跨 Request
跨 Process
跨 Deployment
长期存在
以前前端调用:
await api.createProject(...)
我更多关注:
loading
error
cache invalidation
UI update
现在站在 Backend 侧,需要继续考虑:
Connection
Query
Constraint
Transaction
Persistence
Resource Lifecycle
这是从“消费 API”到“实现可靠数据系统”的明显视角变化。
17. 最终形成的数据链
完成 PostgreSQL 接入后,FrontOps Agent 第一周的 Backend 数据流已经形成:
Client
↓
Fastify Route
↓
Runtime Validation
↓
Service
↓
Repository
↓
Parameterized SQL
↓
PostgreSQL
↓
Database Constraints
反向:
PostgreSQL Row
↓
Repository Mapping
↓
Application Object
↓
Service
↓
Response Serialization
↓
Client
到这里,我已经不再只是:
“会用 Node.js 写接口。”
而开始真正接触 Backend Engineering 的几个核心问题:
Persistence
Resource Lifecycle
Data Integrity
Boundary Mapping
Testing
把 LLM 从 API Demo 变成工程能力
进入第二阶段之后,我终于开始真正接触 LLM API。
如果只看最开始的代码,这件事似乎非常简单:
const response = await client.chat.completions.create({
model: 'deepseek-v4-flash',
messages: [
{
role: 'user',
content: 'Explain Vue reactivity.',
},
],
});
对于一个有多年 Web 开发经验的工程师来说,这段代码几乎没有学习门槛:创建 Client、发请求、等待 Promise、读取 Response。
但这一阶段真正让我开始建立 AI Application Engineering 视角的地方,并不是“会调用模型 API”,而是逐渐意识到:
LLM API 的调用形式虽然像普通 HTTP API,但它带来的工程约束和普通 deterministic backend dependency 并不一样。
这也是 FrontOps Agent 第一次真正从普通 Node.js Backend 向 AI Application 演进。
1. 从 OpenAI API 切换到 DeepSeek:先学机制,而不是绑定 Provider
最开始我使用 OpenAI API 做实验,但实际调用时遇到了 API credits 不足的问题。
由于这一阶段的重点是:
- Model API
- Message
- Structured Output
- Tool Calling
- Agent Loop
- Runtime Validation
- Timeout
- Cancellation
- Testing
而不是某个特定 Provider,因此我把学习阶段的模型切换到了 DeepSeek。
DeepSeek 提供 OpenAI-compatible API,所以仍然可以使用 openai Node SDK:
const deepSeekApiClient = new OpenAI({
apiKey: env.DEEPSEEK_API_KEY,
baseURL: 'https://api.deepseek.com',
timeout: 15_000,
maxRetries: 0,
});
这次切换让我提前建立了一个很重要的概念:
OpenAI SDK
≠
OpenAI Provider
SDK 是 Client/Protocol 层工具,而真正的 Provider 可以是 DeepSeek。
因此后续架构里我没有让业务代码直接依赖 SDK,而是引入自己的 LLMClient boundary。
2. Conversation State 不是模型自动保存的
第一次做多轮对话实验时,我构造了:
system
user
得到模型回复后,再把它作为 assistant message 放入第二次请求:
system
user
assistant
user
这让我确认了一件以前容易被聊天产品 UI 隐藏起来的事实:
Conversation State 首先是 Application State。
最基础的 LLM API 并不是某个始终保存着全部聊天记录的对象。
每次 inference 时,Application 都需要决定本次到底向模型提供哪些 context。
因此随着多轮对话不断增长:
Conversation History
↓
Input Context
↓
prompt_tokens
↓
Cost / Latency Pressure
这也是后续 Context Management、RAG、Agent State 和 Memory 问题出现的根源。
3. Context 是有限资源,不是垃圾桶
我通过模型返回的 usage 做了两个实验。
第一次只发送很短的 messages。
第二次增加 conversation history 和模拟 Repository Context。
结果很直接:
Context ↑
→ prompt_tokens ↑
这个现象看似简单,但对 FrontOps Agent 非常重要。
未来 FrontOps Agent 面对的不是几行文本,而可能是数千甚至上万文件的 Vue / TypeScript Repository。
最粗暴的设计:
Whole Repository
↓
Model
不仅会增加 token cost,还可能增加 latency,并引入大量 irrelevant context。
因此我开始把 Model Context 理解成一个昂贵的 runtime working set,而不是 persistent storage。
更合理的长期方向一定是:
User Question
↓
Search / Retrieve
↓
Relevant Context
↓
Model
也就是说,RAG 和 Tool Calling 并不是为了“让 Agent 看起来更智能”,而是在解决一个已经真实出现的 Context Engineering 问题。
4. Model Output 必须视为 Untrusted Input
这一阶段最重要的工程认知之一,是把第一周学过的 Runtime Validation 迁移到了模型输出。
假设模型应该返回:
type Analysis = {
summary: string;
riskLevel: 'low' | 'medium' | 'high';
};
这种代码:
const result = JSON.parse(output) as Analysis;
实际上没有建立可靠 contract。
JSON.parse() 只能回答:
这是不是合法 JSON?
它无法保证:
summary 是不是 string
riskLevel 是否存在
riskLevel 是否属于允许的 enum
而:
as Analysis
只是 TypeScript compile-time assertion,不会在 Runtime 做任何验证。
因此更准确的数据流应该是:
Raw Model Output
↓
JSON.parse
↓
unknown
↓
Zod Runtime Validation
↓
Trusted Application Value
这里我也重新理解了 Prompt 和 Schema 的职责区别:
Prompt Constraint
→ influence generation
Runtime Validation
→ control acceptance
Prompt 可以要求模型返回合法数据,但不能成为 Application 接受数据的依据。
Zod 也不能保证模型一定生成正确结果,它保证的是错误结果不会被当成可信业务数据继续执行。
5. 从 Demo 调用演进到 Provider Boundary
实验阶段,我可以直接:
await client.chat.completions.create(...)
但进入 FrontOps Agent 正式代码之后,我不希望 AnalysisService 知道:
- DeepSeek
- OpenAI SDK
- baseURL
- Provider HTTP Status
- API Key
因此目前形成了:
AnalysisService
↓
LLMClient
↑
DeepSeekLLMClient
↓
OpenAI-compatible SDK
↓
DeepSeek
AnalysisService 负责业务行为:
return this.llm.generateText({
instruction: `
You are a senior frontend engineer.
Explain the provided frontend code clearly and concisely.
`,
content,
});
而 DeepSeekLLMClient 负责:
- Provider request mapping
- Model selection
- Provider response extraction
- Provider error translation
这让我第一次真正把 LLM 看成一种 Infrastructure Capability,而不是散落在 Service 中的 API 调用。
当前 generateText(): Promise<string> 仍然只是教学阶段 abstraction。
进入 Structured Outputs 和 Tool Calling 后,这个 interface 一定会继续演进。
6. LLM Client 生命周期:不是为了 Singleton Pattern
SDK Client 在 application startup 阶段创建:
Process Start
↓
new OpenAI(...)
↓
register module
↓
listen
↓
Request
Request
Request
而不是:
Request
↓
new OpenAI()
这里的重点并不是背一个“LLM Client 应该使用 Singleton”规则。
真正的原因是 SDK Client 保存的是稳定的 Infrastructure Configuration:
- credential
- endpoint
- timeout
- retry policy
- HTTP configuration
这些并不属于 request-specific state。
因此更合理的方式是让它跟随 Application Process 生命周期,通过 Composition Root / Module Wiring 注入需要它的组件。
这个思路和 PostgreSQL Pool 的生命周期设计原则类似,但两者的资源语义不同,因此不能简单认为 OpenAI Client === DB Pool。
7. Timeout、Abort、Retry 必须分开理解
这一阶段第一次真正让我意识到:
Timeout
Abort
Retry
不是同一件事。
Timeout
Timeout 是 Deadline Policy:
这个操作最多允许执行多久。
Abort
Abort 是 Cancellation Mechanism:
停止一个当前仍在执行的操作。
Retry
Retry 是新的 Execution Attempt:
某次执行失败之后,再发起一次新的执行。
这也是为什么:
Promise.race([modelCall(), timeoutPromise]);
并不等于真正的 cancellation。
Promise.race() 可以让 Application 不再等待 modelCall(),但底层 HTTP 请求仍然可能继续运行。
而在 Agent 场景里,这会继续消耗:
- Network
- Token
- Cost
- Tool resources
因此我在 LLMClient 中加入了 AbortSignal:
export interface LLMClient {
generateText(
input: GenerateTextInput,
options?: {
signal?: AbortSignal;
},
): Promise<string>;
}
并让 signal 从 caller 一路传播:
Caller
↓
AnalysisService
↓
LLMClient
↓
DeepSeekLLMClient
↓
Provider Request
这是后续 Agent Task Cancellation 的最小基础。
8. 为什么当前关闭 SDK 自动 Retry
学习阶段我暂时配置:
maxRetries: 0;
这不是因为 Production LLM 永远不应该 retry。
而是为了让当前执行模型保持清晰:
1 Application Call
=
1 Provider Attempt
否则如果 SDK 内部自动 retry:
10s timeout
↓
retry
↓
10s
↓
retry
Application 看到的一次调用可能已经变成多次真实 Provider Execution。
到了 Agent 系统之后,Retry 还会继续和:
- Token Cost
- Global Deadline
- Tool Side Effects
- Idempotency
- Retry Budget
连接起来。
因此这一阶段我先选择显式失败,后续再单独设计 Production Retry Policy。
9. Provider Error 需要 Translation
业务代码不应该出现:
error instanceof OpenAI.APIError;
否则 Infrastructure-specific type 已经泄漏进 Application。
因此 Provider Adapter 会把 SDK error 转成自己的 LLMError:
SDK / DeepSeek Error
↓
DeepSeekLLMClient
↓
LLMError
↓
Application
例如可以抽象成:
timeout
cancelled
rate_limit
authentication
insufficient_balance
provider_unavailable
unknown
这里还有一个很重要的 HTTP Boundary 问题。
DeepSeek 返回:
401 Unauthorized
并不意味着 FrontOps Agent 的最终用户没有登录。
这个 401 表达的是:
Our Backend
↓
DeepSeek Provider
↓
Provider Credential Failure
因此不能机械向 Vue Frontend 透传 401。
Public API 和 Upstream Provider 处于不同语义边界。
10. AI Application 仍然需要传统测试
这一阶段我把测试拆成四层。
AnalysisService Unit Test
使用 Fake LLMClient。
它验证:
- Application logic
- dependency interaction
- content forwarding
- AbortSignal forwarding
- failure propagation
它不验证 DeepSeek。
Route Test
Mock Service。
它验证:
- HTTP Contract
- Runtime Validation
- Service Invocation
- Response Serialization
Real Model Smoke Test
显式调用真实 DeepSeek。
它验证的是:
API Key
Network
SDK
Provider
Model
这一整条最基本集成链路是否仍然可用。
Smoke Test 不进入默认 pnpm test,避免 watch mode 和普通 CI 无意中不断产生真实模型调用和费用。
Future Agent Eval
Eval 不负责验证 API 是否能调用,而负责:
Agent 的实际 AI 行为质量是否满足要求。
例如:
- 是否找到正确文件
- Tool selection 是否正确
- Patch 是否能通过 TypeCheck
- 回答是否引用正确 source
这让我明确区分了:
Traditional Test
→ Code Correctness
Smoke Test
→ Integration Availability
Eval
→ AI Behavior Quality
11. 当前 FrontOps Agent 的变化
第一周项目主要是:
Route
↓
Service
↓
Repository
↓
PostgreSQL
现在开始变成:
┌→ Repository
│ ↓
Route → Service ────┤ PostgreSQL
│
└→ LLMClient
↓
DeepSeekLLMClient
↓
DeepSeek
FrontOps Agent 第一次同时拥有:
Persistent Capability
+
Model Capability
但现在还不能叫真正的 Agent。
它仍然只是:
Application
↓
Model
↓
Application
下一步 Structured Outputs、Tool Calling 和 Hand-written Agent Loop 才会逐渐让模型从“生成文本”转向“产生受控决策并驱动外部工具”。
从文本生成到可编程数据:我如何为 AI Agent 建立 Structured Outputs 运行时边界
在上一阶段,我已经把 LLM 调用整理成了相对稳定的工程结构:
Application
↓
AnalysisService
↓
LLMClient
↓
DeepSeekLLMClient
↓
LLM Provider
同时加入了 Timeout、Abort、Retry、LLMError、Provider Error Mapping、Unit Test 和 Real Model Smoke Test。
但当我准备让 FrontOps Agent 不再只返回一段自然语言,而是返回可以被程序继续消费的数据时,一个新的问题出现了:
LLM 请求成功,不等于它返回的数据可以安全进入业务系统。
这节课的重点因此不是“怎么让模型返回 JSON”,而是建立一条真正可验证的运行时边界:
LLM Output
↓
Untrusted External Input
↓
JSON Syntax
↓
Schema Validation
↓
Trusted Object
↓
Application
这也是我第一次明显感觉到:AI Application Engineering 并没有绕开传统后端工程,反而因为模型输出具有更强的不确定性,更依赖 Runtime Validation、Error Boundary 和 Testing。
JSON.parse() 只能证明 JSON 语法合法
最开始最容易想到的实现是:
const result = JSON.parse(modelOutput);
但这只能证明:
modelOutput
→ Valid JSON Syntax
例如:
{
"summary": 123,
"risks": "none"
}
完全可以被 JSON.parse() 成功解析。
但如果业务期望:
interface ProjectAnalysis {
summary: string;
risks: string[];
}
这个结果依然不可用。
因此我重新明确了三层不同问题:
JSON.parse
→ Syntax Validation
Schema
→ Structural Validation
Business / Semantic Logic
→ Content Validation
as SomeType 只是骗过 TypeScript
const result = JSON.parse(modelOutput) as ProjectAnalysis;
从 TypeScript 视角看,这之后 IDE 会提供完整类型提示,但这不是真正的类型安全。
TypeScript Type
→ Compile Time
LLM Response
→ Runtime
as ProjectAnalysis 只是开发者告诉编译器“相信我”。
真正可靠的数据流应该是:
unknown
↓
Runtime Validation
↓
ProjectAnalysis
Prompt、JSON Mode、Structured Outputs 和 Zod 是四个不同层次
Prompt
Prompt 是模型指令,是概率性的行为约定,不是 Runtime Contract。
JSON Mode
JSON Mode 主要保证:
Model Output
→ Valid JSON
但不保证符合业务 Schema。
Structured Outputs
Structured Outputs 使用 JSON Schema 对生成过程施加结构约束:
JSON Schema
↓
Provider Generation Constraint
↓
Schema-conforming Output
Zod Runtime Validation
即使 Provider 原生支持 Structured Outputs,我仍然保留:
schema.safeParse(raw);
因为 Provider 是外部系统,Application 自己仍然需要 Trust Boundary。
因此我现在的理解是:
Prompt
→ Semantic Instruction
JSON Mode
→ JSON Syntax Guarantee
Structured Outputs
→ Provider-side Structural Constraint
Zod
→ Application Runtime Trust Boundary
Zod 成为 Structured Output 的 Single Source of Truth
import { z } from 'zod';
export const projectAnalysisSchema = z.strictObject({
summary: z.string().min(1),
architecture: z.array(z.string().min(1)),
risks: z.array(z.string().min(1)),
});
export type ProjectAnalysis = z.infer<typeof projectAnalysisSchema>;
然后:
const jsonSchema = z.toJSONSchema(projectAnalysisSchema);
这样同一个 Schema 同时承担:
Zod Schema
├── TypeScript Type
├── JSON Schema
└── Runtime Validation
避免维护 interface + Zod + JSON Schema 三份 Contract。
为什么我使用 z.strictObject
对于 LLM Structured Output,我更希望额外字段被识别为 Contract Violation,而不是被静默 strip。
{
"summary": "Vue application",
"architecture": ["Vue 3"],
"risks": [],
"unexpected": true
}
当前阶段我希望这个结果直接 Validation Failure。
从 generateText() 演进到 generateObject()
错误的第一反应可能是:
async function generateObject<T>(): Promise<T> {
return JSON.parse(content) as T;
}
这里的 T 只存在于 Compile Time。
因此最终采用 Schema First:
const result = await llm.generateObject({
prompt,
schema: projectAnalysisSchema,
});
而不是:
generateObject<ProjectAnalysis>();
类型来自 Runtime Schema,而不是开发者手写断言。
AnalysisService 不应该知道 Provider 如何实现 Structured Output
当前职责边界是:
AnalysisService
→ 分析什么
→ Semantic Requirement
ProjectAnalysisSchema
→ 输出长什么样
→ Structural Requirement
DeepSeekLLMClient
→ 如何从 Provider 可靠得到对象
→ LLM Runtime Mechanics
因此 AnalysisService 不应该知道:
DeepSeek
JSON Mode
response_format
JSON.parse
z.toJSONSchema
当前 DeepSeek 实现仍是 JSON Mode + Runtime Validation
当前数据流:
Zod Schema
↓
JSON Schema
↓
Prompt Instruction
↓
DeepSeek JSON Mode
↓
JSON.parse
↓
unknown
↓
Zod safeParse
↓
Trusted Object
这里必须明确:
把 JSON Schema 放进 Prompt 仍然只是 Prompt Instruction,不等于 Native Structured Outputs。
未来如果 Provider / Model 支持 Native JSON Schema,变化应该封装在 Adapter 内。
Provider Capability 不应该泄漏到业务层
业务代码不应该出现:
if (provider === 'deepseek') {
...
}
也不应该让调用方传:
useNativeJsonSchema: false;
调用方只表达 Intent:
generateObject({
prompt,
schema,
});
当前项目只有一个 DeepSeek Adapter,所以我没有提前实现 CapabilityRegistry、ProviderStrategyFactory 或 ModelCapabilityResolver。
Structured Output 不能破坏之前的工程能力
在改造 generateObject() 时,我一度遗漏了 AbortSignal。
这个问题提醒我:
旧工程能力
+
新 Agent 能力
↓
更完整的 Runtime
而不是进入新章节后重新写一个孤立 Demo。
Timeout、Abort、Retry、Error Boundary、Testing 都应该继续存在。
Structured Output Error Boundary
一次 generateObject() 可能失败于:
Provider Request
↓
No Choice
↓
Output Truncated
↓
Empty Content
↓
JSON.parse
↓
Zod Validation
因此我区分:
invalid_response
empty_response
output_truncated
invalid_json
invalid_structured_output
并使用:
LLMError.code
→ 稳定分类
cause
→ 详细 Root Cause
避免把所有失败压成一个 Invalid LLM Response。
output_truncated 和 invalid_json 不能混在一起
finish_reason === length 可能最终产生非法 JSON,但真正 Root Cause 是 Generation Incomplete。
所以必须先判断 finish_reason,再进入 JSON.parse()。
这能保留正确的 Failure Semantic,也为未来 Debugging、Observability、Retry Policy 和 Cost Decision 提供依据。
测试暴露了 Production Dependency 过宽
DeepSeekLLMClient 原本依赖完整 OpenAI,但实际只使用 chat.completions.create()。
为了测试而伪造完整 SDK Client 很笨重,因此我把依赖缩窄为 Infrastructure-level 的 ChatCompletionsClient。
这不是为了抽象而抽象,而是:
真实测试痛点
↓
发现依赖过宽
↓
缩窄 Dependency
Generic generateObject() 与 Test Double
generateObject() 是 Generic Method:
generateObject<TSchema extends ZodType>(
input: GenerateObjectInput<TSchema>,
): Promise<output<TSchema>>
直接使用 vi.fn().mockResolvedValue(...) 容易遇到 Generic contextual typing 问题。
我最终没有为了测试框架去破坏 Production Type Design,而是倾向:
Production Generic API
保持正确
Test Double
使用 Fake / Spy
这让我更加明确:
Testability 很重要,但测试框架的限制不应该反向污染正确的 Production Contract。
Structured Output Testing 的分层
| Test | 职责 |
|---|---|
ProjectAnalysisSchema |
Schema Rules |
DeepSeekLLMClient |
Provider Response → Validated Object |
AnalysisService |
Prompt、Schema Selection、Business Flow |
| Route Test | HTTP Request / Response Contract |
| Real Model Smoke Test | Provider Integration |
| Future Eval | Model / Agent Output Quality |
每层只验证自己的 Boundary,避免重复测试。
Smoke Test 和 Eval 不是一回事
Smoke Test 回答:
系统现在还能不能与真实 Provider 正常工作?
Eval 回答:
模型或 Agent 完成任务的质量是否达标?
例如是否准确识别 Pinia、Risk Recall 是否足够、引用文件是否准确,都属于未来 Eval。
Schema Valid 不等于答案正确
{
"architecture": ["Pinia"]
}
即使通过 Schema,也只能证明:
architecture
→ string[]
不能证明 Repository 真的使用了 Pinia。
因此:
Schema Valid
≠
Semantically Correct
Schema Valid 也不等于 Safe To Execute
未来 Tool Calling 中:
{
"path": "../../../etc/passwd"
}
完全可能通过:
z.object({
path: z.string(),
});
但执行前仍然需要检查:
Path 是否存在
Normalize 后是否位于 Repository Root
当前 User / Agent 是否有权限
当前 Operation 是否允许
是否需要 Human Approval
所以:
Schema Validation
→ Data Shape Trust
Permission / Security / Policy
→ Operation Trust
Schema 越严格不一定越好
如果:
riskType: z.enum(['performance', 'security', 'maintainability']);
但真实问题是 accessibility,模型可能漏掉问题或被迫错误分类。
因此 Schema Design 本身也是 Information Model Design,需要在 Reliability 与 Expressiveness 之间权衡。
本课最终工程结论
我现在会对任何 Model Output 依次问:
1. Parseable?
2. Schema Valid?
3. Semantically Correct?
4. Grounded?
5. Allowed?
6. High Quality?
当前阶段真正实现的是前两层。
后面的 Grounding、RAG、Permission、Agent Security、Human Approval、Eval 会在 FrontOps Agent 后续阶段继续补齐。
这节课让我真正理解到:
Structured Outputs 不是“让 LLM 返回 JSON”,而是为不可信模型输出建立可编程、可验证、可测试的 Runtime Contract。
下一阶段进入 Tool Calling 后,模型输出将开始驱动程序执行。到那时,Runtime Validation 不再只是代码质量问题,而会直接成为 Agent Runtime 的执行边界。
Discussion
参与讨论
评论由 GitHub Discussions 提供,需要使用 GitHub 账号登录后参与。