我自己维护的智能体项目已经有段时间了,真正让我决定用 TypeSafe 的思路彻底重构它的,是一次不算严重但很烦躁的线上翻车:Jev 突然在一个多步任务里把文件路径参数传成了数组,而它自己完全没有察觉。表面看只是一个个例,但那一次排查让我意识到,这个项目所有的不确定性都堆在运行时里,越往后越难改。TypeSafe AI 并不是某个框架的名字,而是我把类型安全的思想贯穿到智能体每一个接口上的工程方法,Jev 就是这套方法的试验品。这篇文章把我从设计到落地、再到翻车复盘的过程完整写出来,适合那些不想只做一次性 Demo、想让智能体真正能长期演进的人。
我把 Jev 定位成一个能编排多步任务、调用外部工具并维护对话上下文的智能体。它不是什么炫酷的通用助手,更像是我每天要用的“工作流执行者”。如果你也正在被“模型输出不可控、工具参数乱飞、上下文越积越乱”折磨,那么这篇文章里应该有不少你用得上的东西。
1. 从“能跑就行”到“类型安全感”:Jev 为什么被重写
1.1 大多数智能体项目的隐性债务
智能体项目刚起步时,“能跑就行”是一种很自然的策略。我最早的 Jev 原型也逃不过这个套路:给一大段系统提示词,让模型返回 JSON,代码拿到 JSON 后直接用点号取字段。Demo 跑起来非常快,加了几个工具也能演示,看起来一切正常。
但代价来自维护阶段。模型输出天然不稳定,哪怕是同一个 prompt,这一轮返回{"steps": ["a", "b"]},下一轮可能就变成{"steps": [{"action": "a"}, {"action": "b"}]}。更难受的是,结构漂移不会立刻报错,通常是在某个下游逻辑里才炸出莫名奇妙的异常。等到异常真正出现时,问题早就不是“模型返回了错误字段”这么简单,而是“这一段对话历史里到底哪条消息污染了上下文”全链路排查。
这是典型的隐性债务:前期靠 prompt 约束住一切,后期每个小改动都要担心会不会让某个原本很少出现的失误变成高频失误。我曾经尝试用一堆“容错解析”函数去兜底,比如先判断是不是数组、如果不是再转一下。结果这些容错代码本身变成了新的复杂度来源,一层套一层,最后连我自己都不敢轻易改动。
1.2 确定性代码与不确定性模型之间的断层
如果把一个智能体拆开看,问题最集中的有三处:模型输出的解析、工具调用的参数校验、上下文状态的维护。这三处在传统写法里都是动态类型的地带,也就是“差不多能跑,但没人能保证它一定对”的地带。
为了把这个问题说清楚,我列过一个对比表:
| 故障位置 | 传统做法的表现 | 引入类型约束之后 |
|---|---|---|
| 模型输出解析 | 运行时才发现字段缺失,报错位置离真正原因很远 | 输出先经过 schema 校验,结构不合法立刻失败并给出细节 |
| 工具调用参数 | 模型生成的参数直接透传给函数,类型错乱只会在函数内部暴露 | 工具定义与参数校验共用同一份 schema,进入函数前的类型已被确认 |
| 上下文维护 | 消息数组里什么形状都有,靠代码边读边猜 | 每条消息都有明确的可辨识联合类型,非法消息在入口就被挡住 |
这个对比不是我预先想好的理论,而是重构 Jev 过程中逐项解决的问题。每一行都是真实踩过的坑。
1.3 Jev 重写后的目标
重写不是要把 Jev 变成“严格到死板的机器”。我的目标很简单:让每一次模型交互都变成可验证的契约,让智能体的每一步行为都有类型证据。具体来说有三条:
- 所有进入模型的系统提示词、工具定义都有对应的 schema 来源,不再手写两套。
- 所有从模型返回的数据,先完成结构校验再进入业务逻辑,不允许 unknown 直接漏过去。
- 所有工具注册都必须声明输入输出类型,让模型调用工具的接口像函数签名一样清晰。
这个目标听起来有点抽象,落实到代码上其实是几层设计。下一章我会讲最核心的一层:模型交互的端口化。
2. 把模型交互变成带类型边界的“端口”:TypeSafe AI 的核心设计
2.1 三类契约:提示词、工具定义、结构化输出
我做了个简单的划分,一个智能体和模型之间,本质上只存在三类契约需要管理:
- 系统提示词里关于能力与输出格式的描述
- 工具定义的 JSON Schema,用于让模型理解有哪些工具可用
- 结构化输出的 JSON Schema,用于约束模型返回内容的形状
传统做法里这三者是分开维护的,提示词里写一段“你必须返回包含 title 和 steps 的 JSON”,工具定义里写一份 JSON Schema,输出校验代码里再手写一份类型。这三份东西只要有任何一处不一致,运行时就可能出现诡异行为。TypeSafe AI 的思路是:这三份内容必须同源。
同源的意思是,定义一份权威的 schema,用它同时生成给模型看的描述、给运行时用的校验逻辑、以及给代码用的 TypeScript 类型。我习惯这样组织一个解析器:
type Parser<T> = { parse: (raw: unknown) => T; toJsonSchema: () => object; }; function createParser<T>(schema: object, parseFn: (raw: unknown) => T): Parser<T> { return { parse: parseFn, toJsonSchema: () => schema, }; }注意这里的parse和toJsonSchema都来自同一个schema。这避免了常见的“工具定义一种形状、校验代码写另一种形状”的两头跑问题。任何修改只需要改一处,两边的行为自动同步。
2.2 模型调用端口:进出都要过闸机
有了 Parser 之后,我把每次模型调用定义成一个“端口”。一个端口就是一个函数,它接收一组已经验证过的参数,调用模型,然后返回一组也已验证过的结果。模型没有任何机会把原始输出直接泄漏到业务代码里。
type ModelPort<In, Out> = { input: Parser<In>; output: Parser<Out>; call: (input: In) => Promise<Out>; }; async function callModelPort<In, Out>( port: ModelPort<In, Out>, rawInput: unknown ): Promise<Out> { const input = port.input.parse(rawInput); const rawOutput = await port.call(input); return port.output.parse(rawOutput); }这个模式的收益很直接:当你拿到port.output.parse返回的结果时,类型已经收窄到了你期待的形状,后续业务代码不需要像以前那样四处 “as any” 或者各种 Array.isArray 兜底。
我有一个比较实用的经验:千万不要把“校验失败”当成“请求失败”来处理。很多智能体项目把 JSON parse 失败简单记一条错误日志然后返回空对象,这是灾难的开始。校验失败应该是一个独立的异常类型,带着原始输出、期望 schema 和失败原因,这样你才能知道模型行为在往哪个方向漂移。Jev 的错误结构大致是这样:
type ModelOutputError = { kind: 'model_output_error'; rawOutput: unknown; expectedSchema: object; issues: string[]; };有了这种错误类型,每次校验失败都能被观测到,不再是黑盒。
2.3 用可辨识联合表示动作
智能体的核心循环是:模型决定下一步要做什么。无论选择是“调用某个工具”还是“直接回复用户”,我都用一个可辨识联合来承载:
type ToolCallAction = { type: 'call_tool'; toolName: string; input: unknown; }; type ReplyAction = { type: 'reply'; content: string; }; type AgentAction = ToolCallAction | ReplyAction;为什么这一步非常重要?因为模型返回的 action 在一开始只有type是可置信的,而toolName和input都需要二次校验。这个类型设计迫使你写代码时永远先做类型收窄,再做业务逻辑。
比如执行工具调用时,我没有把input直接展开,而是让它经过对应工具自身的 Parser:
function executeAction(action: AgentAction, tools: ToolRegistry) { if (action.type === 'reply') { return { kind: 'reply', content: action.content }; } const tool = tools.get(action.toolName); if (!tool) { return { kind: 'tool_error', error: `unknown tool: ${action.toolName}`, }; } const parsedInput = tool.inputParser.parse(action.input); return tool.execute(parsedInput); }这里tool.inputParser.parse(action.input)是最后一道保险。模型返回的 input 可能是乱写一气的对象,只有经过工具的专属 schema 校验之后,才允许进入真正执行的函数。
2.4 错误类型也要有契约
很多人设计的智能体代码里,错误就是throw new Error('something happened')。在 Demo 阶段这没什么,但是在需要把错误反馈给模型重新规划的场景里,这种字符串错误完全不够用。Jev 里所有工具返回值都遵循一个 Result 模式:
type ToolResult<T> = | { ok: true; data: T } | { ok: false; error: ToolError }; type ToolError = { code: string; message: string; recoverable: boolean; };recoverable这个字段我强烈建议加上。它告诉模型和上层编排逻辑:这个错误是“换一种参数重试就行”,还是“此路不通,得换工具或者直接告诉用户”。模型在重新规划时能根据这个字段做出更合理的决策,而不是机械地把同样的工具再调一百遍。
3. Jev 的技能、工具与记忆:类型安全在三个模块里的落地细节
3.1 技能注册表:把参数校验提前到类型层
Jev 里的“技能”是组合好的动作单元,可能是一系列工具调用的编排,也可能只是带特定提示词的工具包装。每个技能注册时需要同时声明输入输出 schema:
type Skill<In, Out> = { name: string; description: string; inputParser: Parser<In>; outputParser: Parser<Out>; execute: (input: In, ctx: AgentContext) => Promise<Out>; };这里最关键的点在于:execute函数的入参类型是由inputParser推导出来的,而不是靠开发者凭记忆手写。写技能的人必须先定义inputParser,然后 execute 的参数类型自然就有了。我曾经在重构时把这段类型链打通,效果立竿见影:新增技能时 TypeScript 会强制你先把输入输出描述清楚,否则编译都过不去。
3.2 第三方工具适配层的类型升降级
Jev 接过的外部工具五花八门,有的返回干净的 JSON,有的返回带一堆历史遗留字段的嵌套结构。我的做法是强制两层处理:先 normalize 再 validate。
type ExternalToolResponse = { normalize: (raw: unknown) => unknown; validate: Parser<NormalizedData>; }; function createToolAdapter<T>(normalize: (raw: unknown) => unknown, parser: Parser<T>): ExternalToolResponse { return { normalize: (raw) => normalize(raw), validate: parser }; }这么设计的原因很实际:外部 API 的原始返回里可能有你不需要的噪音字段,也可能字段名风格和内部不一致。如果直接拿原始数据去套 schema,很可能因为一个无关紧要的额外字段或者大小写问题而失败。先 normalize 成一个内部干净的结构,再交给 validate,整个链路就稳定了。
我在适配过程中整理过一张常见问题表,分享给后来者:
| 外部数据常见问题 | 处理方法 | 备注 |
|---|---|---|
| 返回字段名大小写不一致 | normalize 阶段统一小驼峰 | 不要试图用 schema 兼容多种命名 |
| 成功和失败使用不同根结构 | 先把成功/失败拆开,再分别定义 schema | 失败分支不要硬塞进正常结果类型 |
| 列表为空时返回 null | normalize 成空数组 | 下游逻辑只处理数组,永不处理 null |
| 时间字段格式混用 | 统一成 ISO 字符串再进 schema | 时间类型不归模型负责,适配层负责 |
3.3 记忆与上下文:状态不再是一锅 JSON
Jev 的上下文管理曾是我最头疼的部分。因为对话历史里既有用户消息,又有工具调用记录,还有系统日志,如果全塞进一个messages: any[],一旦某条消息结构异常,整个上下文都会出问题。
我最终把消息模型定成可辨识联合:
type ChatMessage = | { role: 'user'; content: string } | { role: 'assistant'; content: string; toolCalls?: ToolCallAction[] } | { role: 'tool'; toolCallId: string; result: ToolResult<unknown> };每一类消息都必须在入口处经过校验才能进入上下文。这个改动带来的好处在长期维护中体现得最明显:因为每类消息都是封闭类型,新增一种消息类型时,所有处理这些消息的代码都会被编译器的穷尽检查提醒到。
对于长期记忆,我也加了一层元数据。每段记忆不仅有content,还有source、confidence、timestamp这些字段。这看起来只是多存几个字段,但它让记忆的读写变成了可验证的流程,而不是一个把字符串塞进去再指望模型能理解的暗箱。
4. 实测与翻车复盘:Jev 在真实任务中的三个教训
4.1 多步任务编排:好工具也会被模型“用错”
第一次完整测试,我给 Jev 布置了一个典型任务:搜索三份资料,汇总成摘要,然后存入文档库。搜索技能、摘要生成、文档存储都注册好了,理论上链路是通的。
但实测第一次就翻车:Jev 在调用“搜索资料”这个工具时,把max_results传成了字符串"三",而 schema 要求的是数字。TypeSafe 的端口设计在这里起到了作用——参数没有静默透传,而是被工具校验拦截,返回了一个可恢复的错误。Jev 接收到这个错误后,重新调整了参数,换成了数字 3,第二次调用成功。
这个例子恰好说明了一个容易被忽略的点:类型安全不是用来“阻止模型犯错”的,而是用来把错误变成可观测、可恢复的。模型一定会犯错,关键是不要让错误直接变成不可理解的运行时崩溃。
4.2 上下文切换:任务到闲聊的边界怎么找
另一个不算事故、但设计上让我思考很多的现象是上下文切换。用户在做任务的中途突然问了一句无关的问题。Jev 会把这句话当成任务的额外输入,导致后续的工具调用参数都带上了一堆和任务无关的字段。
后来我加了一个简单的上下文栈机制:任务执行中引入的临时上下文与全局对话上下文分开管理。当任务开始时,Jev 会 check-in 一个任务上下文;任务结束时 check-out,回到全局上下文。每个上下文都有独立的类型定义,任务上下文专注工具调用记录,全局上下文专注用户意图。这样即使聊跑题了,任务上下文也不至于被污染。
type TaskContext = { taskType: string; goal: string; toolCalls: Array<{ toolName: string; input: unknown; result: ToolResult<unknown> }>; currentStep: number; };这个设计让“任务临时状态”和“长期用户上下文”各归其位,重构后我再也没见过那种“用户聊了一句天气,任务参数里出现温度字段”的荒唐情况。
4.3 动态工具调用:重新规划不是玄学
工具会失败,这是必然的。TypeSafe 的 Result 模式在这里发挥的作用是:错误信息要结构化到模型能看懂、能决策。有一回 Jev 调用一个定时任务技能时,依赖的服务返回超时。如果没有recoverable: false的标记,模型可能会继续重试几十次。加上这个标记后,Jev 很快判断出应该换一条路径,改为直接告知用户稍后重试。
结构化错误信息让“重新规划”从玄学变成了可预期的行为。模型不再需要靠猜来判断这个错误是否值得重试,因为错误类型本身已经给出了答案。
4.4 一次险些漏掉的上下文污染
最后分享一个最有价值的翻车复盘。某天 Jev 在长时间对话后出现了异常行为:用户在问一个和之前任务毫无关系的简单问题时,Jev 却突然调用了文档库工具,仿佛还停留在上一个任务里。我检查了上下文栈,逻辑上没有毛病,但行为明显不对。
排错链路大概是这样的:
- 首先怀疑工具定义污染,检查 ToolRegistration,发现所有工具定义都是按端口生成的,没有异常。
- 然后怀疑系统提示词太长,截断了任务上下文,发现也不对。
- 最后把整个上下文导出,逐条查看历史消息,发现有一条历史消息里的
toolCallId字段与后续工具结果消息引用不一致,导致 Jev 的注意力被错误地牵引。
根因是早前一个版本里,上下文消息保存时没有对toolCallId做类型校验,导致一条残留消息虽然通过了基本结构校验,却带着不匹配的 ID。后来我给所有toolCallId字段增加了格式校验,并且在进入上下文前检查引用一致性。这次排查之后我最大的体会是:类型安全不是静态类型那么简单,运行时校验和类型推导必须同时在线,少一个都不行。
5. 方法论迁移:这条类型安全路线还能怎么用
5.1 模型可替换性变得很轻松
TypeSafe 设计带来的一个隐藏收益:模型本身变成可替换的。因为 Jev 的所有业务逻辑都只依赖端口契约,而不依赖某个特定模型的输出习惯。想换模型时,只需要适配“模型调用”这一层,把新模型的输出解析成同样的契约即可。
这一点在 Demo 里看不出来,在长期维护里太重要了。因为模型迭代很快,今天选型的最优方案,半年后可能就不是了。如果所有业务代码都散落着对特定模型输出结构的假设,替换模型的成本会高到让你宁愿继续忍受旧模型。Jev 没有这个问题,换模型基本就是换一个端口实现。
5.2 测试变得更像测试
过去测智能体项目非常痛苦:要么真的去调模型,又慢又费钱;要么做一堆极其脆弱的 mock,模型返回结构一变,测试全挂。引入 Parser 抽象之后,测试变得很明确:给定输入 schema,测输出解析结果是否严格通过;给定不合法输出,测是否抛出可预期的错误。这些测试完全不依赖模型,跑得又快又稳。
有个具体经验:我为每个工具都写了一套“非法输入”测试。工具校验器拿到错误数据时必须返回可恢复的错误,而不是抛异常。这个测试把智能体最大的风险——模型乱传参数——牢牢框住。
5.3 三条省心建议
如果你也想在自己项目里引入 TypeSafe AI 的思路,我给三条最实在的建议:
- 别等全部设计好再动手。从一个工具开始,建立“schema -> 工具定义 -> 参数校验”的链路,跑通之后再扩展。
- 别用宽松校验骗自己。
output: JSON.parse(...)不算校验,必须检查必填字段和值类型。宁可早失败,不可晚崩溃。 - 痛点驱动。哪里运行时 bug 最多,就先给哪里加类型边界。通常最先受益的地方就是模型输出解析和工具参数校验。
最后说一点我自己的体会。过去一年多,我见过很多智能体项目都死在同一个地方:第一版跑得快,三个月后没人敢动。TypeSafe AI 不是让项目变得“慢”,而是让项目在持续演化的过程中仍然保持可控。Jev 到现在仍然只是一个中型项目,但因为它每个接口都有清晰的类型边界,我能在半年后再打开代码时依旧快速定位问题。这份“改得动”的安心感,就是类型安全给我最大的回报。