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: anytoolResult: anymessage.meta?: anyplannerOutput as SomeType
刚开始你会觉得这样写很快。等到工具多起来、状态字段多起来、一次 run 有十几个步骤的时候,排错会非常痛苦。你会看到字段名到处漂,状态结构前后不一致,工具返回值今天是字符串,明天是对象,后天又包了一层 data。
TypeScript 在这里最大的价值不是“写起来更高级”,而是帮你给系统立边界。
3. 在 Agent 项目里,类型最该先管哪几层
不是所有地方都要一开始就写得很细。真要落地,优先级大概是这样的:
- tool 输入输出
- agent state
- message / event / step record
- run result
- 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用可辨识联合,不要把 成功失败混在一个松散对象里summary和data分开,前者给模型或日志看,后者给系统消费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 步才报一个完全不相干的错。