跳到主要内容

Agent 项目里的 TypeScript 落地实践

这篇文档想解决的不是 “TypeScript 有哪些语法”,也不是 “Agent 是什么”。

真正的问题通常更具体:

  • 为什么现在大多数 Agent 项目最后都会落到 TypeScript
  • 一个 Agent 工程里,类型到底该管哪些东西
  • tool、message、state、memory、run result 应该怎么收口
  • SDK 接进来之后,哪些地方最容易失控
  • 第一版代码怎么写,后面才不至于一路 any 下去

如果你已经会一点 TypeScript,也已经知道 Agent 大概是怎么回事,但一写到真实项目就开始发散,这篇就是写给这种阶段的。

1. 为什么这类内容更适合放在 AI 模块

表面上看,这当然属于 TypeScript 的使用场景。

但只要真的做过一版 Agent 项目,很快就会发现,难点根本不在 “联合类型怎么写”,而在下面这些事情:

  • 一次 run 的状态怎么定义
  • 工具输入输出怎么约束
  • 模型返回结果怎么兜底
  • 什么时候该把状态持久化,什么时候只放在内存里
  • 失败以后是重试、补问、换工具,还是直接结束

这些问题的核心更接近 Agent 工程,而不是通用 TS 语法。

所以我更建议把主体放在 AI/agent-engineering,TypeScript 章节里只留跳转。这样结构更清楚,后面也更好扩。

2. Agent 项目为什么天然适合 TypeScript

我自己的判断是,Agent 项目和 TypeScript 的契合度,比普通前端页面还高。

原因不复杂。

普通页面里,很多对象的边界是比较稳定的。一个按钮、一个表单、一个接口列表,形状大致可预期。Agent 不是。Agent 里到处都是半结构化输入:

  • 用户自然语言
  • 模型输出
  • 工具参数
  • 工具返回值
  • 中间状态
  • 运行日志
  • 评估结果

这些对象如果没有类型约束,项目一大,代码会很快变成一种“表面可跑,实际全靠猜”的状态。

最常见的坏味道就是:

  • state: any
  • toolResult: any
  • message.meta?: any
  • plannerOutput as SomeType

刚开始你会觉得这样写很快。等到工具多起来、状态字段多起来、一次 run 有十几个步骤的时候,排错会非常痛苦。你会看到字段名到处漂,状态结构前后不一致,工具返回值今天是字符串,明天是对象,后天又包了一层 data

TypeScript 在这里最大的价值不是“写起来更高级”,而是帮你给系统立边界。

3. 在 Agent 项目里,类型最该先管哪几层

不是所有地方都要一开始就写得很细。真要落地,优先级大概是这样的:

  1. tool 输入输出
  2. agent state
  3. message / event / step record
  4. run result
  5. config 和 env

这几层一旦稳住,项目的骨架就稳了。

反过来说,如果这几层是散的,后面你再补日志、补 memory、补评估,都会越来越乱。

4. 一份更接近真实项目的目录结构

先别急着上框架。看目录比看 SDK 更能说明问题。

一个我觉得比较实用的 Agent TypeScript 项目结构,大概会长这样:

src/
agents/
support-agent/
index.ts
decide.ts
prompts.ts
state.ts
tools.ts
tools/
crm/
get-customer.ts
docs/
search-docs.ts
browser/
open-page.ts
schemas/
tool-schemas.ts
event-schemas.ts
types/
agent.ts
message.ts
tool.ts
run.ts
runtime/
logger.ts
memory-store.ts
retry.ts
config/
env.ts
evals/
cases/
harness.ts

这套结构背后的想法很简单:

  • agents/ 放 agent 自己的决策逻辑
  • tools/ 放实际能力调用
  • schemas/ 放运行时校验
  • types/ 放共享的类型边界
  • runtime/ 放运行时公共能力
  • config/ 放配置和环境变量
  • evals/ 放评估,不要混进业务代码里

很多人第一版喜欢把所有东西写进一个 agent.ts。能跑,但维持不了多久。只要工具开始增多,或者同一个 runtime 要支持多个 agent,拆分几乎是必然的。

5. 先把 tool 类型定稳

一个 Agent 系统,最容易扩散的地方就是 tool。

因为 tool 天然跨边界:

  • 上面接模型的 tool call
  • 中间接你的业务逻辑
  • 下面接数据库、HTTP、浏览器、搜索、文件系统

如果 tool 没有统一接口,每加一个工具,风格就会漂一点。

一个比较稳的起点可以像这样:

export type ToolContext = {
runId: string;
userId?: string;
abortSignal?: AbortSignal;
};

export type ToolResult<TData = unknown> =
| {
ok: true;
data: TData;
summary?: string;
}
| {
ok: false;
error: string;
retryable?: boolean;
};

export type ToolDefinition<TInput, TOutput> = {
name: string;
description: string;
run: (input: TInput, context: ToolContext) => Promise<ToolResult<TOutput>>;
};

这里有几个细节很重要:

  • ToolResult 用可辨识联合,不要把成功失败混在一个松散对象里
  • summarydata 分开,前者给模型或日志看,后者给系统消费
  • retryable 明确标出来,后面重试逻辑才好写
  • context 统一收口,不要每个工具自己发明一套上下文参数

这类定义看起来朴素,但它能拦住后面很多混乱。

6. 输入输出别只靠 TypeScript,最好再加一层 schema

这是 Agent 项目里特别容易踩坑的一点。

很多人会写成这样:

type SearchDocsInput = {
query: string;
topK?: number;
};

类型没错,但这只能约束编辑期。

如果这个参数来自:

  • 模型 tool call
  • HTTP 请求
  • 队列消息
  • 外部 JSON

那你最后还是得面对一个事实:运行时拿到的东西不一定长这样。

所以更稳的做法通常是:

import { z } from "zod";

export const SearchDocsInputSchema = z.object({
query: z.string().min(1),
topK: z.number().int().positive().max(20).default(5),
});

export type SearchDocsInput = z.infer<typeof SearchDocsInputSchema>;

这样做不是因为“大家都在用 zod”,而是因为 Agent 项目里外部输入太多了。只靠 type 不够。

如果你想单独把这部分补完整,可以接着看 运行时校验与 Zod

我会把规则说得直接一点:

  • 类型负责静态边界
  • schema 负责运行时边界

两层都要有。

7. state 类型不要贪大,先把运行状态收窄

Agent state 一开始最容易犯的错,就是把所有东西都堆进去,然后定义成一个越来越胖的对象。

比如:

type AgentState = {
goal: string;
messages: unknown[];
memory: unknown[];
facts: Record<string, unknown>;
toolHistory: unknown[];
currentPlan: unknown;
output: unknown;
};

这类 state 看起来像是“先占个位,后面再细化”,但实际开发里通常很难再收回来。

更好的做法是先按职责拆:

type AgentStatus = "running" | "waiting_for_tool" | "waiting_for_human" | "done" | "failed";

type EvidenceItem = {
source: string;
summary: string;
confidence?: number;
};

type ToolCallRecord = {
step: number;
toolName: string;
startedAt: string;
finishedAt?: string;
ok: boolean;
summary: string;
};

type AgentState = {
runId: string;
goal: string;
status: AgentStatus;
currentStep: number;
maxSteps: number;
evidence: EvidenceItem[];
toolHistory: ToolCallRecord[];
finalAnswer: string | null;
lastError?: string;
};

这样写的好处是,state 本身就在逼你想清楚:

  • 哪些是流程控制字段
  • 哪些是证据
  • 哪些是工具轨迹
  • 哪些是最终产物

这些东西分不清,后面日志、评估、恢复、回放都会乱。

8. message 类型最好从第一天就分角色

很多 Agent 项目最开始只会写:

type Message = {
role: string;
content: string;
};

能用,但太粗。

如果系统会经过多轮思考、工具调用和人工介入,更实用的写法通常是:

type SystemMessage = {
role: "system";
content: string;
};

type UserMessage = {
role: "user";
content: string;
};

type AssistantMessage = {
role: "assistant";
content: string;
};

type ToolMessage = {
role: "tool";
toolName: string;
content: string;
ok: boolean;
};

type AgentMessage =
| SystemMessage
| UserMessage
| AssistantMessage
| ToolMessage;

这不是为了“写得更学术”,而是为了让后面的逻辑判断别全靠字符串猜。

比如:

  • 哪类消息能进入模型上下文
  • 哪类消息只该写日志
  • tool message 失败时是否需要重试
  • 哪类消息允许写入 memory

这些判断如果一开始没分角色,后面很容易一路打补丁。

9. decision 类型比 prompt 本身更该先稳定

很多人写 Agent 时,注意力会一直放在 prompt 上。

prompt 当然重要,但从工程角度看,decision 的类型边界往往更关键。因为系统最后落地,不是靠“模型想了什么”,而是靠“它决定做什么”。

一个很常见的收口方式是:

type AgentDecision =
| {
type: "tool";
toolName: string;
args: Record<string, unknown>;
reason: string;
}
| {
type: "ask_human";
question: string;
reason: string;
}
| {
type: "finish";
answer: string;
reason: string;
};

它的作用非常实际:

  • 把模型输出压成几个可执行分支
  • 避免“模型写了一段很像结论的话,但系统不知道该怎么执行”
  • 后面更容易做日志、评估和 replay

如果一个 Agent 跑起来像黑箱,很多时候问题不是模型不够强,而是中间决策结构太散。

10. memory 不是一个数组字段,最好先分短期和长期

Agent 项目里另一个常见混淆是:把所有“记住的东西”都叫 memory。

更实用的分法通常是:

  • 短期 memory:这次 run 里临时保留的上下文
  • 长期 memory:跨 run 复用的用户偏好、历史事实、已确认信息

类型上可以先这么拆:

type WorkingMemory = {
extractedFacts: Record<string, string | number | boolean>;
openQuestions: string[];
};

type LongTermMemoryItem = {
key: string;
value: string;
source: string;
updatedAt: string;
};

这件事看着像建模洁癖,其实非常现实。你不拆,后面就会出现:

  • 一次 run 的临时推断被当成长期事实
  • 旧记忆没过期还一直在用
  • 模型把上一轮猜的东西当成用户确认过的东西

这类 bug 非常常见,而且都不太好查。

11. run result 要面向“系统消费”,不要只面向“人类阅读”

很多第一版 Agent 最后只会返回一段文本:

return "我已经帮你完成任务";

如果只是 Demo,够了。真到项目里,一般不够。

更实用的 run result 常常至少会带:

type AgentRunResult = {
ok: boolean;
runId: string;
finalAnswer: string;
status: "completed" | "failed";
stepsUsed: number;
toolCalls: number;
error?: string;
};

因为最终结果不只是给用户看,往往还要给:

  • 上层 API
  • dashboard
  • eval harness
  • 失败重试逻辑
  • 审批节点

如果返回结构一开始就只面向 UI 文案,后面很容易重构。

12. env 和 config 也该算 Agent 工程的一部分

Agent 项目特别依赖配置,而且很多配置不是“有没有都行”的那种。

常见的有:

  • 模型名
  • API key
  • 超时
  • 最大步数
  • 是否允许某类工具
  • 检索 topK
  • 日志等级

更稳的做法不是到处 process.env.X,而是集中收口:

import { z } from "zod";

const EnvSchema = z.object({
OPENAI_API_KEY: z.string().min(1),
AGENT_MODEL: z.string().default("gpt-4.1"),
AGENT_MAX_STEPS: z.coerce.number().int().positive().default(8),
});

export const env = EnvSchema.parse(process.env);

这会少掉很多很烦的隐性错误。比如本地能跑,线上换了环境变量名,系统却在第 8 步才报一个完全不相干的错。

13. 一个最小但不太“玩具”的 Agent 骨架

下面这份代码不是某个 SDK 的官方模板,而是一份更偏工程骨架的简化示例。重点不在能不能直接上线,而在边界长得对不对。

type ToolContext = {
runId: string;
};

type ToolResult<TData = unknown> =
| { ok: true; data: TData; summary: string }
| { ok: false; error: string; retryable?: boolean };

type ToolDefinition<TInput, TOutput> = {
name: string;
run: (input: TInput, context: ToolContext) => Promise<ToolResult<TOutput>>;
};

type AgentDecision =
| { type: "tool"; toolName: string; args: Record<string, unknown>; reason: string }
| { type: "finish"; answer: string; reason: string };

type AgentState = {
runId: string;
goal: string;
currentStep: number;
maxSteps: number;
evidence: string[];
finalAnswer: string | null;
};

async function runAgent(state: AgentState): Promise<AgentState> {
let next = { ...state };

while (next.currentStep < next.maxSteps && !next.finalAnswer) {
const decision: AgentDecision = await decide(next);

if (decision.type === "finish") {
next.finalAnswer = decision.answer;
break;
}

const result = await callTool(decision.toolName, decision.args, {
runId: next.runId,
});

next.currentStep += 1;

if (result.ok) {
next.evidence.push(result.summary);
} else {
next.finalAnswer = `Agent failed: ${result.error}`;
break;
}
}

return next;
}

这段代码的价值不在“它写得多完整”,而在它至少先把几个关键边界定住了:

  • decision 是有限分支
  • tool result 是成功 / 失败联合
  • state 里哪些字段是核心运行态,一眼能看出来

第一版把这些定住,比一开始把所有高级能力都接进来更重要。

14. 接 OpenAI / AI SDK / LangGraph 时,类型重点不一样

这一点挺重要。很多人以为“用了框架,类型问题就解决了”。其实只是问题换地方了。

OpenAI Agents / Responses 一类 SDK

重点通常在:

  • tool schema
  • 消息结构
  • 事件流
  • 最终输出解析

这里最容易出问题的是:模型返回结构和你业务层需要的结构不是一回事。SDK 类型给你兜的是“协议层”,不是“你自己的业务结果”。

Vercel AI SDK

重点通常在:

  • generateObject / streamObject 的 schema
  • UI message 和 server message 的分层
  • structured output 的稳定性

它很适合“输出对象结构明确”的场景,但你还是得自己定义业务侧的状态和事件。

LangGraph / 工作流型框架

重点通常在:

  • state shape
  • node 输入输出
  • 分支路由条件
  • durable execution 里的恢复状态

这类框架一旦 state 类型没定好,图会越长越难维护。

所以别把“框架自带类型”理解成“项目类型设计已经完成”。两件事不是一回事。

15. 真正容易踩的坑

坑 1:any 从工具层一路扩散

最典型的起点是:

const result: any = await tool.run(args);

一旦这里开了口子,后面 statedecisionfinalAnswer 都会被拖着一起变松。

坑 2:把模型输出直接 as 成目标类型

const decision = modelOutput as AgentDecision;

这种写法短期看很省事,长期几乎一定出事。因为它跳过了最关键的一步:确认模型输出真的符合你的可执行结构。

坑 3:把工具返回值和给模型看的摘要混在一起

模型需要的是可读摘要,系统需要的是结构化结果。混在一起,后面很容易两头都不满意。

坑 4:state 越攒越胖

一开始图省事,后来每遇到一个需求就往 state 里塞一个字段。最后没有人说得清哪些字段是输入,哪些是推断,哪些是缓存。

坑 5:把评估结果写成自然语言,不写结构

Agent 项目的评估如果只有“这次表现不错”,后面几乎没法自动比较。评估结果也应该有类型边界。

16. 如果你要把 TypeScript 真正接进一个现有 Agent 项目

不要一下子全改。更稳的顺序通常是:

  1. 先把 tool 输入输出补类型
  2. 再把 state 和 decision 收紧
  3. 再补 env / config schema
  4. 再补 message、event、run result
  5. 最后处理 memory 和 evals

这个顺序的好处是,前几步就能明显降低混乱,而且不会一上来就动太多文件。

17. 建议怎么和这组文档一起看

如果你正在系统学 Agent 工程,这篇更适合放在这些文章之间一起读:

  1. Agent Engineering
  2. Workflow vs Agent
  3. Agent Memory and State
  4. Harness Engineering
  5. TypeScript 在 Agent 项目中的落地实践
  6. Minimal Agent TypeScript 模板
  7. Tool-Using Agent TypeScript 模板
  8. RAG Agent TypeScript 模板
  9. Research Agent TypeScript 模板

这个顺序比一上来就盯模板更顺一点。先知道为什么要这样拆,再去看模板,很多细节会更容易对上。

18. 最后一句话

Agent 项目里的 TypeScript,重点从来不是“把代码写得更像 TypeScript”。

重点是把系统里那些本来很容易发散的边界,尽量收窄。工具是工具,状态是状态,推断是推断,事实是事实。边界清楚了,后面的调试、评估、重试、恢复、扩工具,都会轻很多。