1. 从"agent-native"这个词说起:它到底在解决什么问题
第一次看到"agent-native"这个说法,很多人会下意识把它归类成又一个框架营销词。但如果你最近半年真正动手写过带工具调用能力的应用,就会明白这个词背后指向的痛点非常具体:我们过去写的应用,是给人用的;而 agent-native 应用,是给"会自己决策的智能体"用的。这两者的架构假设完全不同。
传统应用的核心循环是"用户点击 → 系统响应 → 返回结果"。而 agent-native 应用的核心循环是"目标输入 → 智能体规划 → 调用工具 → 观察结果 → 再规划 → 直到完成或放弃"。注意这里的关键差异:控制流不再由 UI 事件驱动,而是由模型的推理结果驱动。这意味着你的代码结构、状态管理、错误处理、日志体系,全都要重新设计。
我拿 TypeScript 生态来举例,因为这是目前 agent-native 落地最活跃的技术栈之一。TypeScript 的类型系统天然适合描述"工具契约"——每个工具接受什么参数、返回什么结构、可能抛什么错误,这些都可以用类型精确表达。当智能体在运行时决定调用哪个工具时,类型系统能在编译期就帮你挡掉大量低级错误。这也是为什么热词里agent-native、TypeScript、framework、agentic apps会绑在一起出现。
这篇文章适合三类人看:一是已经用 TypeScript 写过 LLM 应用、但代码越写越乱的开发者;二是想理解 agent-native 架构到底和普通"调 API"有什么本质区别的技术负责人;三是准备面试、被问到"你怎么设计一个 agent 框架"的求职者。我会从架构假设、核心抽象、工具契约设计、状态与记忆、错误恢复、可观测性几个层面,把这件事讲透,并且给出可以直接抄的代码结构。
先说一个反直觉的结论:agent-native 框架最难的部分不是调用模型,而是"如何让智能体在失败后还能继续工作"。大部分 demo 在顺利路径上跑得很好,一旦工具报错、模型输出格式不对、上下文超长,整个流程就崩了。真正的工程价值,恰恰在那些"不顺利"的路径上。
2. agent-native 与传统应用架构的分水岭
2.1 控制反转:谁在决定下一步做什么
传统应用里,下一步做什么是开发者写死的。用户点了按钮 A,就走分支 A;接口返回 404,就弹提示。整个决策树是静态的,你可以画成流程图。
agent-native 应用里,下一步做什么是模型在运行时决定的。你给它一个目标"帮我把这份合同里的风险条款标出来",它可能先去读文件,发现是 PDF,于是调用 PDF 解析工具;解析出来发现是扫描件,于是调用 OCR;OCR 结果里发现关键条款,于是调用比对工具。这条路径你事先根本写不出来,因为它是根据中间结果动态生成的。
这个差异带来的第一个工程后果是:你不能再依赖"穷举所有分支"来保证正确性。你必须设计一套机制,让智能体在遇到没见过的分支时,能做出合理决策,或者至少能安全地停下来求助。
2.2 状态管理的重心转移
传统应用的状态,核心是"UI 状态 + 业务数据"。agent-native 应用的状态,核心是"对话历史 + 工具调用记录 + 中间产物 + 当前目标"。这四样东西构成了智能体的"工作记忆"。
我见过太多项目把这几样东西混在一个大数组里,结果就是:上下文越来越长,模型越来越糊涂,最后连自己刚才调过什么工具都忘了。正确的做法是把它们分层:
| 状态层 | 内容 | 生命周期 | 是否进上下文 |
|---|---|---|---|
| 目标层 | 用户原始意图、约束条件 | 整个会话 | 始终保留 |
| 规划层 | 当前子任务、待办列表 | 动态更新 | 摘要后保留 |
| 执行层 | 工具调用参数与结果 | 单步 | 按需裁剪 |
| 产物层 | 生成的文件、结构化数据 | 持久化 | 只存引用 |
这张表是我踩了很多坑之后总结的。关键洞察是:不是所有状态都应该塞进模型的上下文窗口。执行层的原始结果往往很长,你只需要把"结论"喂回去,原始数据存到外部,用 ID 引用即可。
2.3 错误处理的哲学差异
传统应用的错误处理是"捕获 → 记录 → 返回友好提示"。agent-native 应用的错误处理是"捕获 → 让智能体理解错误 → 决定重试/换方案/放弃"。
举个例子,智能体调用一个查询天气的工具,返回了"API rate limit exceeded"。传统做法是直接报错给用户。agent-native 的做法是把这条错误信息作为观察结果喂回模型,模型可能会决定"等 30 秒再试"或者"换一个备用数据源"。这就要求你的错误信息必须是模型能读懂的,而不是给程序员看的堆栈。
提示:工具返回的错误信息,要写成自然语言描述,包含"发生了什么"和"可以怎么办"两部分。比如不要返回
Error: 429,而要返回请求过于频繁,当前配额已用尽,建议等待约 30 秒后重试,或改用备用接口。
3. 用 TypeScript 定义工具契约:类型即文档
3.1 为什么工具定义是整个框架的地基
在 agent-native 架构里,工具就是智能体的"手脚"。模型再聪明,如果工具定义得含糊,它也调不对。我见过最典型的翻车场景:一个工具叫search,参数是query: string,结果模型不知道该传关键词还是传自然语言句子,每次调用效果都飘忽不定。
工具定义的质量,直接决定了智能体的上限。好的工具定义应该满足三个条件:名字自解释、参数有约束、返回值有结构。
用 TypeScript 的话,我推荐用 schema 优先的方式定义工具,而不是靠注释。因为 schema 可以被运行时校验,也可以被转换成模型能理解的 JSON Schema。
import { z } from "zod"; const SearchToolSchema = z.object({ query: z.string().describe("搜索关键词,建议使用 2-5 个核心词,不要用完整句子"), maxResults: z.number().int().min(1).max(20).default(5) .describe("返回结果数量,默认 5 条"), dateRange: z.enum(["day", "week", "month", "year", "all"]) .default("all") .describe("时间范围过滤") }); type SearchToolInput = z.infer<typeof SearchToolSchema>;注意.describe()里的文字。这些描述不是给程序员看的,是给模型看的。它们会直接进入模型的工具选择上下文。所以描述要写得像"给一个聪明但完全不了解你系统的实习生交代任务"那样清楚。
3.2 参数设计的几个反直觉经验
第一,参数越少越好,但不要少到需要模型猜。我见过一个工具把format参数省了,结果模型每次都要猜输出格式,行为极不稳定。后来加上format: "json" | "markdown" | "plain"并给了默认值,稳定性立刻上来了。
第二,枚举类型比自由字符串可靠得多。如果某个参数只有几种合法取值,一定要用 enum 约束。模型在自由字符串上容易发挥创意,在枚举上则老实得多。
第三,给默认值,但要在描述里说明默认行为。模型看到有默认值,往往就不传了,这时候默认值必须符合大多数场景的预期。
第四,避免嵌套过深的参数结构。模型处理扁平参数的成功率明显高于深层嵌套。如果确实需要复杂结构,考虑拆成多个工具,或者用字符串化的 JSON 加校验。
3.3 返回值设计:给模型"结论"而不是"原始数据"
这是我最想强调的一点。工具返回值的设计,决定了模型能不能高效利用结果。
假设你有一个查询订单的工具,返回一个包含 50 个字段的订单对象。模型拿到这一大坨,很可能抓不住重点。更好的做法是返回一个经过提炼的结构:
const OrderResultSchema = z.object({ orderId: z.string(), status: z.enum(["pending", "shipped", "delivered", "cancelled"]), summary: z.string().describe("一句话概括订单当前状态"), keyDates: z.object({ created: z.string(), estimatedDelivery: z.string().optional() }), anomalies: z.array(z.string()).describe("异常情况列表,如延迟、缺货等,无异常则为空数组") });summary和anomalies这两个字段是专门为模型设计的。它们把"需要模型自己推理才能得出的结论"提前算好了。这不是偷懒,而是把确定性计算和不确定性推理分开——能用代码算准的,就别让模型猜。
4. 智能体循环的骨架:规划、执行、观察、再规划
4.1 一个最小可用的循环结构
抛开各种框架的花哨封装,agent-native 的核心就是一个循环。我用伪代码加 TypeScript 混合的方式给你看骨架:
async function runAgent(goal: string, tools: Tool[], maxSteps = 20) { const history: Message[] = [{ role: "user", content: goal }]; for (let step = 0; step < maxSteps; step++) { const response = await callModel({ messages: history, tools: tools.map(toToolSchema), toolChoice: "auto" }); history.push(response.message); if (response.finishReason === "stop") { return response.message.content; } if (response.finishReason === "tool_calls") { for (const call of response.toolCalls) { const result = await executeTool(call, tools); history.push({ role: "tool", toolCallId: call.id, content: serializeResult(result) }); } } } throw new Error("达到最大步数仍未完成"); }这个骨架看起来简单,但每一行都有讲究。maxSteps是必须的,否则模型可能陷入死循环。serializeResult也不是简单 JSON.stringify,后面会讲。
4.2 规划不是一次性动作,而是持续行为
很多教程把"规划"讲成第一步:让模型先列个待办清单,然后照着做。这在简单任务上可行,但真实场景里,计划赶不上变化。工具返回的结果可能推翻原有假设,这时候死守原计划就是灾难。
我的做法是:轻规划,重观察。不强制模型一开始就输出完整计划,而是让它在每一步都重新评估"当前离目标还差什么"。具体实现上,可以在系统提示里加一句引导:
在每次调用工具前,先用一句话说明你这一步想达成什么。如果上一步的结果改变了你的判断,直接调整方向,不要被之前的思路束缚。
这句话看起来不起眼,但实测能显著减少"一条道走到黑"的情况。
4.3 观察结果的序列化:别把 JSON 直接丢回去
工具返回的结果,怎么喂回模型,是个大学问。直接JSON.stringify有几个问题:字段名可能对模型没意义、嵌套结构浪费 token、特殊字符可能干扰解析。
我的经验是做一个面向模型的序列化层:
function serializeForModel(result: unknown): string { if (typeof result === "string") return result; if (Array.isArray(result)) { return result.map((item, i) => `[${i + 1}] ${serializeForModel(item)}`).join("\n"); } if (typeof result === "object" && result !== null) { return Object.entries(result) .filter(([, v]) => v !== null && v !== undefined) .map(([k, v]) => `${k}: ${serializeForModel(v)}`) .join("\n"); } return String(result); }这个函数把结构化数据转成"键值对换行"的文本形式。实测下来,模型对这种格式的理解准确率比原始 JSON 高不少,而且更省 token。
5. 记忆与上下文管理:agent-native 最容易被低估的部分
5.1 上下文窗口不是越大越好
现在很多模型支持超长上下文,于是有人就把所有历史一股脑塞进去。结果呢?模型注意力被稀释,关键信息淹没在噪声里,响应变慢,成本飙升。
上下文管理的本质是信息压缩。你要在"保留足够信息让模型做对决策"和"控制长度保证模型注意力集中"之间找平衡。
我的分层策略是这样的:
- 最近 3-5 轮对话:完整保留,包括工具调用细节
- 更早的对话:压缩成摘要,只保留结论和关键决策
- 工具原始结果:超过一定长度就存外部,上下文里只放摘要加引用 ID
- 系统提示:始终完整保留,这是行为准则
5.2 摘要怎么做才不丢信息
摘要最怕的是把关键约束条件给摘没了。比如用户一开始说"预算不超过 5000,必须支持导出 PDF",如果摘要时丢了这两条,后面智能体可能就推荐了超预算方案。
我的做法是结构化摘要,而不是自由文本摘要:
const ConversationSummarySchema = z.object({ userGoal: z.string().describe("用户的原始目标,尽量保留原话"), hardConstraints: z.array(z.string()).describe("硬性约束,如预算、格式、时间等"), decisionsMade: z.array(z.string()).describe("已经做出的关键决策"), openQuestions: z.array(z.string()).describe("尚未解决的问题"), artifacts: z.array(z.object({ id: z.string(), type: z.string(), description: z.string() })).describe("已产生的产物引用") });这样摘要出来的东西,关键信息一个不落,而且结构稳定,模型每次都能按同样的方式理解。
5.3 长期记忆:什么时候需要,怎么存
不是所有 agent 都需要长期记忆。如果你的智能体只处理单次会话,那会话结束记忆就该清空。但如果它要跨会话记住用户偏好、历史决策,就需要一个持久化层。
我的建议是:长期记忆只存"稳定的事实",不存"临时的推理"。比如"用户偏好简洁的回复风格"值得存,"用户上次问的是天气"不值得存。存储形式上,向量检索适合模糊匹配,结构化存储适合精确查询,两者可以结合。
注意:长期记忆一定要有"遗忘机制"。存进去容易,清理难。我建议给每条记忆加时间戳和访问计数,定期清理长期未被访问的条目,否则记忆库会越来越臃肿,检索质量越来越差。
6. 失败恢复:让智能体在出错后还能继续干活
6.1 工具失败的三种类型与应对
工具失败大致分三类,处理方式完全不同:
| 失败类型 | 例子 | 应对策略 |
|---|---|---|
| 瞬时失败 | 网络超时、限流 | 自动重试,带退避 |
| 参数错误 | 参数格式不对、缺必填项 | 把错误信息喂回模型,让它修正参数 |
| 能力缺失 | 工具不支持该操作 | 让模型换工具或告知用户 |
关键区别在于:瞬时失败应该由框架自动处理,不该打扰模型;参数错误应该让模型自己修;能力缺失才需要上升到用户。
我见过很多实现把所有错误都直接抛给模型,结果模型对着一个网络超时反复重试,浪费大量 token。正确的做法是在工具执行层做重试,只有重试也失败才把错误上报。
6.2 参数错误的自动修复循环
参数错误是最常见的,也是最容易自动修复的。做法是:工具执行前先做 schema 校验,校验失败时,把校验错误信息格式化后返回给模型,让它重新生成参数。
async function executeToolWithRetry(call: ToolCall, tools: Tool[], maxRetries = 2) { const tool = tools.find(t => t.name === call.name); if (!tool) { return { error: `不存在名为 ${call.name} 的工具,可用工具:${tools.map(t => t.name).join(", ")}` }; } for (let attempt = 0; attempt <= maxRetries; attempt++) { const parsed = tool.schema.safeParse(call.arguments); if (parsed.success) { try { return await tool.execute(parsed.data); } catch (e) { if (attempt === maxRetries) { return { error: `工具执行失败:${describeError(e)}` }; } await sleep(2 ** attempt * 500); } } else { return { error: `参数校验失败:${formatZodError(parsed.error)}。请修正参数后重试。` }; } } }注意formatZodError的输出要人性化。Zod 默认的错误信息对模型不太友好,我通常会转成"字段 X 期望是数字,但收到了字符串"这种自然语言。
6.3 死循环的识别与打断
智能体陷入死循环是真实存在的风险。典型表现是:反复调用同一个工具、反复生成相似的参数、在两个方案之间来回横跳。
识别死循环的简单办法是记录最近 N 步的工具调用签名(工具名 + 参数哈希),如果出现重复,就触发干预。干预方式可以是:在上下文里插入一条系统提示"你似乎陷入了重复,请重新审视目标,考虑换一种方法",或者直接终止并返回当前最佳结果。
7. 可观测性:没有日志的 agent 就是黑盒
7.1 必须记录的几类信息
agent-native 应用如果出问题,排查难度远高于传统应用,因为决策路径是动态的。所以可观测性必须从第一天就设计好。我建议至少记录:
- 每一步的输入上下文(可以脱敏,但要能还原决策依据)
- 模型的原始输出(包括思考过程和工具调用)
- 工具调用的参数与结果(含耗时)
- token 消耗与成本
- 最终结果与用户反馈
这些信息串起来,才能还原"智能体为什么做了这个决定"。
7.2 用 trace 串起一次完整会话
单条日志价值有限,把一次会话的所有步骤串成一条 trace才有用。每个 trace 有唯一 ID,每个 step 有序号,这样你可以像看录像一样回放整个决策过程。
interface AgentTrace { traceId: string; goal: string; steps: Array<{ index: number; type: "model_call" | "tool_call" | "error"; input: unknown; output: unknown; durationMs: number; tokenUsage?: { input: number; output: number }; }>; finalResult: unknown; totalDurationMs: number; }有了这个结构,你可以做很多分析:哪类工具最容易失败、平均几步完成任务、token 主要消耗在哪里。这些数据是优化的依据。
7.3 一个容易被忽略的调试技巧
调试 agent 时,把模型的"思考过程"单独存一份。很多模型支持输出推理内容,这些内容对理解模型为什么这么决策极其有价值。我通常会把推理内容和最终动作分开存储,排查问题时先看推理,往往一眼就能看出模型在哪一步理解偏了。
8. 关于 agent-native 框架选型的一些实在话
8.1 要不要用现成框架
这是被问最多的问题。我的观点是:先手写一遍最小循环,再决定要不要用框架。原因很简单,如果你不理解循环的本质,用框架也只是在调 API,出了问题根本不知道怎么排查。
手写一遍之后,你会对"工具定义、上下文管理、错误恢复"这些核心问题有切身体会。这时候再看框架,就能判断它到底帮你解决了什么,哪些地方反而限制了你的灵活性。
8.2 评估框架的几个维度
如果确实要用框架,我建议从这几个维度评估:
- 工具定义方式:是否支持 schema 校验,是否类型安全
- 上下文管理:是否提供压缩、裁剪机制,还是全丢给你
- 错误处理:是否有重试、降级机制
- 可观测性:是否内置 trace,能否接入现有监控
- 模型兼容性:是否绑定特定模型,切换成本高不高
- 社区活跃度:出问题能不能找到人问
TypeScript 生态里,类型安全是最大优势,选框架时一定要看它的类型定义质量。类型定义潦草的框架,用起来会很痛苦。
8.3 自研还是集成的判断标准
我的经验法则是:如果你的需求 80% 以上能被框架覆盖,就用框架;如果框架只能覆盖 50%,自研反而更快。因为 agent-native 的定制化需求往往很深,框架的抽象一旦不匹配,改起来比重写还费劲。
另外,框架的更新速度要跟得上模型能力的演进。模型能力几个月就上一个台阶,框架如果半年不更新,很可能已经过时了。
9. 我在实际项目里踩过的几个坑
第一个坑是过度依赖模型的格式遵循能力。早期我让模型直接输出 JSON,结果它时不时加个 markdown 代码块包裹,或者加句"好的,这是结果"。后来改用工具调用(function calling)机制,让模型通过结构化接口输出,稳定性立刻上来了。能用结构化接口的,就别让模型自由发挥格式。
第二个坑是工具粒度过细。一开始我把每个小操作都做成独立工具,结果模型要在十几个工具里选,选择困难,经常选错。后来合并成几个粗粒度工具,每个工具内部处理多个步骤,模型的选择准确率明显提升。工具数量控制在 5-15 个之间比较合适,太少不够用,太多选不准。
第三个坑是忽略 token 成本。demo 阶段不在意,上线后发现成本高得吓人。后来做了几件事:工具结果做摘要、历史对话做压缩、简单任务用小模型、复杂任务才用大模型。成本降了一大半,效果几乎没损失。
第四个坑是没有超时控制。某个工具卡住了,整个流程就挂在那里。后来给每个工具调用加了超时,超时后返回"操作超时,请考虑换一种方式",模型就能自己调整。
第五个坑是测试用例覆盖不足。agent 的行为是概率性的,同一个输入可能走出不同路径。我后来建了一个"回归测试集",把典型场景和边界场景都放进去,每次改动后跑一遍,看成功率有没有下降。这个习惯救了我很多次。
10. 给准备上手的人几条实用建议
如果你现在就要开始写第一个 agent-native 应用,我的建议是从最小的闭环开始。不要一上来就设计复杂的多智能体协作,先做一个能调用两三个工具、能完成一个具体小任务的单智能体。跑通之后,再逐步加工具、加记忆、加错误恢复。
工具定义上,先写清楚描述,再写实现。描述写不清楚,说明你对这个工具要解决什么问题还没想明白。描述写清楚了,实现往往水到渠成。
上下文管理上,从第一天就做分层。别等到上下文爆炸了再重构,那时候改动成本很高。
错误处理上,把"模型能读懂的错误信息"当成一等公民。错误信息写得好,智能体的自愈能力就强。
最后,一定要做可观测性。没有 trace 的 agent 就是个黑盒,出了问题你只能靠猜。有了 trace,你才能持续优化。
agent-native 这个方向还在快速演进,今天的最佳实践可能半年后就过时了。但有些底层原则是稳定的:清晰的工具契约、分层的状态管理、健壮的错误恢复、完善的可观测性。把这些打扎实,无论上层框架怎么变,你都能快速适应。