1. 为什么“agent-native”值得单独拎出来聊
第一次看到“agent-native”这个词,我的反应是:又一个造词运动。毕竟前端圈每年都要冒出几十个新概念,什么“AI-first”“serverless-native”“edge-native”,听多了容易麻木。但真正动手写过几个 Agent 应用之后,我改主意了——这个词背后指向的问题非常具体,而且用传统的应用架构去解,确实会越解越别扭。
先把话说清楚:agent-native 不是某个具体的库或框架,而是一种应用架构取向。它指的是——把“智能体(Agent)”当作应用的一等公民来设计,而不是在传统 CRUD 应用上外挂一个聊天框。传统做法是:先有页面、路由、状态管理、数据库表,然后加一个“AI 助手”按钮,点开是个对话框,背后调一次大模型接口。agent-native 的做法反过来:应用的核心是若干个能感知上下文、能调用工具、能维护自身状态的 Agent,UI 只是这些 Agent 运行过程的一个可视化窗口。
这个区别听起来抽象,落到代码上非常实在。举个最直观的例子:传统应用里,“用户点击按钮 → 触发一个函数 → 函数返回结果 → 渲染”。agent-native 应用里,“用户表达意图 → Agent 规划步骤 → Agent 选择工具 → 工具执行 → Agent 观察结果 → 决定下一步或结束 → 流式输出给 UI”。前者是确定性的调用链,后者是带循环、带分支、带不确定性的执行图。你拿 React 的useState去管理后者的状态,很快就会发现自己在一堆isLoading、isStreaming、toolCallPending之间反复横跳。
所以这篇东西适合谁看?如果你正在用 TypeScript 写带 AI 能力的应用,尤其是那种“不止一个对话框、还要调工具、还要多轮规划”的场景,那 agent-native 这套思路能帮你少走很多弯路。如果你只是想在页面右下角放个问答机器人,那传统做法完全够用,不必为了概念而重构。判断标准很简单:你的应用里,Agent 是主角还是配角。配角就用传统架构,主角就认真考虑 agent-native。
我踩过的第一个坑,就是拿一个标准的 Next.js 项目硬塞 Agent 逻辑。页面、API Route、数据库都写好了,然后发现 Agent 的执行是长时的、可中断的、需要持久化中间状态的,而我的 API Route 是无状态的、一次性的。两边根本对不上。后来才明白,agent-native 的第一性问题不是“怎么调模型”,而是“怎么组织一个会自己跑一段时间的执行体”。
2. agent-native 的核心设计思路拆解
2.1 从“请求-响应”到“感知-规划-行动”的范式切换
传统 Web 应用的骨架是请求-响应:客户端发一个请求,服务端处理完返回,连接结束。这个模型统治了二十多年,因为它简单、可预测、易缓存。但 Agent 的工作方式天然不是这样的。一个 Agent 接到任务后,典型流程是:感知当前状态(读上下文、读记忆)→ 规划下一步(可能调模型推理)→ 执行一个动作(调工具、查数据、写文件)→ 观察结果 → 再规划。这是一个循环,循环次数不确定,可能一步结束,也可能跑十几步。
这个差异带来的连锁反应很大。请求-响应模型下,超时是异常;Agent 模型下,跑三十秒是常态。请求-响应模型下,失败就重试整个请求;Agent 模型下,失败可能发生在第三步,你需要从第三步恢复而不是从头再来。请求-响应模型下,状态在客户端或数据库;Agent 模型下,状态在“执行图”的每个节点上,需要能被序列化和反序列化。
我在设计第一个 agent-native 项目时,犯的典型错误是把 Agent 的执行当成一个长请求。结果就是:用户关掉页面,Agent 就死了;网络抖一下,整轮推理白跑。后来改成“执行体 + 事件流”的模型才顺过来——Agent 的执行是一个独立于 HTTP 连接的生命周期,HTTP 只是订阅它事件流的一个通道。这个思路一转,很多问题迎刃而解。
2.2 为什么 TypeScript 是 agent-native 的天然搭档
热词里 TypeScript 出现频率极高,这不是偶然。agent-native 应用对类型系统的依赖,比普通前端应用强得多。原因在于 Agent 的“工具调用”本质上是一个动态分发问题:模型输出一段结构化数据,说“我要调用名为 X 的工具,参数是 Y”,你的运行时需要把这段数据映射到真实的函数上,并且校验参数。这个过程如果没有类型系统兜底,就是一场灾难。
TypeScript 在这里的价值体现在三个层面。第一是工具定义的单一事实来源:你用类型定义工具的输入输出,然后用类型工具(比如zod配合z.infer)自动生成运行时校验和给模型看的 JSON Schema,一份定义三处复用,不会出现“文档写的和代码实现不一致”的经典问题。第二是执行图的状态类型:Agent 每一步的输入输出、记忆条目、工具结果,都可以用 discriminated union 建模,编译器会强制你处理所有分支,避免运行时才发现漏了某个状态。第三是流式事件的类型安全:Agent 执行过程中会吐出各种事件(思考中、调用工具、工具返回、最终答案),用联合类型定义事件,前端消费时能精确 switch,不会出现“这个字段有时候有有时候没有”的玄学 bug。
对比一下用动态语言写同样的逻辑:工具参数校验靠手写 if,执行状态靠any或字典,事件类型靠约定。项目小的时候没事,一旦工具有二十个、状态有十几种,维护成本指数上升。这也是为什么我后来坚定地把 agent-native 项目放在 TypeScript 上——不是信仰,是省命。
2.3 框架选型:别急着上重型方案
热词里 framework 反复出现,很多人第一反应是找个“Agent 框架”来用。我的建议是:先想清楚你要的是编排能力还是运行时能力。这两者经常被混在一起,但其实是两件事。
编排能力指的是:定义 Agent 有哪些步骤、步骤之间怎么连、条件分支怎么走。这部分用代码写完全没问题,一个async函数加几个if就能表达,不一定需要框架。运行时能力指的是:执行状态怎么持久化、怎么中断恢复、怎么并发、怎么流式输出、怎么和前端同步。这部分才是真正麻烦的地方,也是框架能帮上忙的地方。
我试过几种路线。纯手写:灵活,但每个项目都要重新造一遍状态机和事件流,重复劳动。用通用工作流引擎:能力强,但概念重,为了一个简单的 Agent 要学一整套 DSL,杀鸡用牛刀。用轻量的 Agent 编排库:折中,但要注意它的抽象是否泄漏——有些库把模型调用、工具执行、状态管理全包了,看起来很爽,一旦你想定制某个环节就会发现处处受限。
实测下来比较舒服的组合是:编排用普通 TypeScript 代码 + 少量辅助函数,运行时用成熟的状态持久化和流式方案。这样编排逻辑透明可控,运行时该借力就借力。具体到工具选型,状态持久化可以考虑基于事件溯源(event sourcing)的思路,把 Agent 的每一步作为事件存下来,恢复时重放;流式输出用标准的 Server-Sent Events 或 WebSocket,前端按事件类型分发。这套组合不依赖特定框架,迁移成本低。
3. 核心细节解析与实操要点
3.1 工具(Tool)的设计:Agent 的手脚怎么造
Agent 能不能干活,全看工具有没有设计好。我见过太多项目,模型能力没问题,但工具设计得一塌糊涂,导致 Agent 要么调错工具,要么参数传错,要么根本不知道该调哪个。工具设计的核心原则有三条。
第一条,工具粒度要匹配模型的认知粒度。什么叫认知粒度?就是模型在“想”的时候,脑子里冒出来的动作单元。比如你要让 Agent 查天气,工具叫getWeather(city)就比callWeatherApi(endpoint, params, headers)好得多。后者是给程序员看的,前者是给“意图”看的。模型想的是“我要知道北京天气”,不是“我要发一个 GET 请求到某个 endpoint”。粒度太细,模型要拼好几步才能完成一件事,容易出错;粒度太粗,工具内部逻辑复杂,参数多,模型也难填对。经验值是:一个工具对应一个明确的业务动作,参数控制在五个以内。
第二条,工具描述要写给模型看,不是写给人看。很多人写工具描述像写 JSDoc,什么“@param city 城市名称”,模型看了等于没看。好的工具描述应该包含:这个工具做什么、什么时候该用、什么时候不该用、参数的含义和格式、返回什么。举个例子,查天气的工具描述可以写成:“查询指定城市的当前天气。当用户询问天气、气温、是否下雨时使用。参数 city 为城市中文名,如‘北京’‘上海’。返回温度、天气状况、湿度。”这样模型判断该不该调、怎么填参数,准确率高很多。
第三条,工具要能优雅地失败。Agent 调工具失败是常态,网络超时、参数非法、外部服务挂了。关键是失败信息要结构化地返回给模型,让模型能决定是重试、换工具还是告诉用户。我习惯把工具返回统一成{ ok: boolean, data?: T, error?: { code, message, retryable } }的结构。模型看到retryable: true就知道可以再试一次,看到retryable: false就知道该换路子。这个细节能显著提升 Agent 的鲁棒性。
下面是一个工具定义的骨架,用 TypeScript 加 zod 实现,一份定义同时产出运行时校验和给模型的 schema:
import { z } from "zod"; const getWeatherSchema = z.object({ city: z.string().describe("城市中文名,如 北京、上海"), }); type GetWeatherInput = z.infer<typeof getWeatherSchema>; const getWeatherTool = { name: "get_weather", description: "查询指定城市的当前天气。当用户询问天气、气温、是否下雨时使用。", schema: getWeatherSchema, async execute(input: GetWeatherInput) { try { const data = await weatherApi.query(input.city); return { ok: true, data }; } catch (e) { return { ok: false, error: { code: "WEATHER_API_ERROR", message: String(e), retryable: true }, }; } }, };注意:工具名用下划线命名(
get_weather)而不是驼峰,是因为部分模型对下划线分隔的标识符识别更稳。这个不是硬性规定,但实测下来小写加下划线最不容易出岔子。
3.2 记忆(Memory)的分层:别把所有东西塞进上下文
Agent 的记忆管理是另一个重灾区。新手最常见的做法是把所有历史对话一股脑塞进 prompt,跑几轮之后上下文爆了,成本飙升,模型还开始“失忆”——因为关键信息被淹没在噪音里。agent-native 的思路是把记忆分层,不同层用不同策略。
我一般分三层。工作记忆:当前这一轮任务的相关信息,比如用户刚说的话、正在处理的文件内容。这层直接进上下文,量小、时效性强。会话记忆:整个对话的历史,但不全量进上下文,而是做摘要或检索。简单做法是保留最近 N 轮原文,更早的做滚动摘要。长期记忆:跨会话的知识,比如用户偏好、项目背景,存在外部存储里,需要时按相关性检索出来注入。
这里有个关键决策:什么时候检索,什么时候全量注入。我的经验是,工作记忆全量注入,会话记忆按窗口注入,长期记忆按需检索。检索的触发点可以是每轮开始前,用当前输入去查相关记忆;也可以是 Agent 主动调用一个“回忆”工具。后者更灵活,但要求模型有“我可能需要回忆”的自觉,实际用下来前者更稳。
记忆的存储格式也值得说一句。别存成纯文本,存成结构化的条目:{ id, type, content, embedding?, createdAt, relevanceHints }。这样检索、去重、过期清理都好做。我踩过的坑是早期把记忆存成一个大字符串,后来想按时间过滤、想删掉某条,全靠字符串操作,痛苦不堪。
3.3 执行循环的控制:怎么防止 Agent 跑飞
Agent 自己跑循环,最大的风险是跑飞——无限循环、反复调同一个工具、越跑越偏。控制手段有几个层次。
最基础的是步数上限。给每个任务设一个最大步数,比如 15 步,到了就强制结束并返回当前结果。这个兜底必须有,不然一个 bug 能让你的 API 账单爆炸。其次是重复检测:如果连续几步调用了同一个工具、参数还差不多,大概率是卡住了,应该中断或换个策略。再进一步是预算控制:按 token 消耗或工具调用次数设预算,超了就停。
更精细的做法是让模型自己意识到该停了。在系统提示里明确写“如果任务已完成,调用 finish 工具结束;如果无法完成,调用 give_up 工具说明原因”。给模型一个明确的退出通道,比让它自己判断“我是不是该停了”要可靠。我实测下来,加了显式退出工具之后,Agent 跑飞的概率下降非常明显。
还有一个容易被忽略的点:中断与恢复。用户可能中途改主意,或者想暂停。agent-native 应用应该支持在执行到某一步时中断,保存当前状态,之后从断点继续。这就要求每一步的状态都是可序列化的,且执行逻辑是幂等的或可重放的。这个能力在 demo 里看不出价值,上了生产就是刚需。
4. 实操过程与核心环节实现
4.1 从零搭一个最小 agent-native 执行体
光说思路容易飘,我拿一个具体的最小实现走一遍。目标:一个能查天气、能算数、能根据结果继续推理的 Agent,用 TypeScript 写,支持流式输出和中断恢复。
第一步,定义事件类型。Agent 执行过程中会产出的事件,用 discriminated union 建模:
type AgentEvent = | { type: "thinking"; content: string } | { type: "tool_call"; toolName: string; input: unknown; callId: string } | { type: "tool_result"; callId: string; result: unknown } | { type: "message"; content: string } | { type: "done"; reason: "finished" | "max_steps" | "error" };这个联合类型是整个系统的骨架。前端消费时switch (event.type),编译器保证你处理所有分支。后端产出时也只能产出这几种,不会出现“意外事件”。
第二步,定义执行状态。状态要能被序列化,所以不能存函数引用:
type AgentState = { taskId: string; step: number; messages: Array<{ role: string; content: string }>; pendingToolCalls: Array<{ callId: string; toolName: string; input: unknown }>; status: "running" | "waiting_tool" | "done" | "error"; };第三步,写执行循环。核心逻辑是:调模型 → 解析输出 → 如果有工具调用就执行 → 把结果加回消息 → 再调模型,直到模型给出最终答案或触发退出条件。
async function runAgent( state: AgentState, emit: (e: AgentEvent) => void ): Promise<AgentState> { const MAX_STEPS = 15; while (state.step < MAX_STEPS && state.status === "running") { state.step += 1; const response = await callModel(state.messages, tools); if (response.toolCalls?.length) { for (const call of response.toolCalls) { emit({ type: "tool_call", toolName: call.name, input: call.input, callId: call.id }); const result = await executeTool(call.name, call.input); emit({ type: "tool_result", callId: call.id, result }); state.messages.push({ role: "tool", content: JSON.stringify(result) }); } } else { emit({ type: "message", content: response.content }); state.status = "done"; } } if (state.step >= MAX_STEPS) { emit({ type: "done", reason: "max_steps" }); } return state; }这段代码看着简单,但每一行背后都有讲究。MAX_STEPS是兜底,必须有。emit把执行过程外化成事件流,前端可以实时渲染。状态在循环中不断更新,且每一步之后都可以持久化,支持中断恢复。
4.2 流式输出与前端同步的落地细节
Agent 执行是长时的,用户不能干等着。流式输出是必须的,但怎么流、流什么,有讲究。我的做法是:后端把 AgentEvent 序列化成 SSE 推给前端,前端按事件类型渲染不同的 UI 组件。
thinking事件渲染成“正在思考”的提示,tool_call渲染成“正在调用 XX 工具”,tool_result渲染成工具返回的摘要,message渲染成最终回答。这样用户全程知道 Agent 在干什么,体验比转圈圈好太多。
实现上,SSE 比 WebSocket 简单,单向推送够用。关键是事件要有序、要能重连。给每个事件加一个递增的seq,前端记录最后收到的seq,断线重连时带上,后端从那个位置继续推。这个细节不做,网络一抖用户就看到重复内容或丢内容。
还有一个坑:流式输出和状态持久化的顺序。如果先推事件再存状态,推成功了但存失败了,重连时状态对不上。正确顺序是先存状态再推事件,或者用事务保证两者一致。我早期图省事先推后存,结果重连时经常出现“事件收到了但状态没更新”的诡异现象,排查了半天。
4.3 工具执行的隔离与超时
工具执行是 Agent 和外部世界交互的地方,也是最容易出问题的地方。三个必须做的防护:超时、隔离、幂等。
超时好理解,每个工具调用设一个上限,比如 10 秒,超了就返回retryable: true的错误。隔离指的是工具执行不能影响主流程,一个工具挂了不能把整个 Agent 拖死。做法是把工具执行包在 try-catch 里,任何异常都转成结构化的错误返回。幂等指的是同一个工具调用重复执行结果应该一致,或者至少副作用可控。对于写操作类的工具,要特别小心——Agent 可能因为重试而重复执行。
我踩过的一个真实坑:一个 Agent 调“发送邮件”工具,因为网络超时触发了重试,结果用户收到了两封一样的邮件。后来给所有写操作工具加了幂等键(idempotency key),同一个逻辑操作只执行一次,问题才解决。这个教训是:读操作可以随便重试,写操作必须幂等。
| 防护项 | 读操作 | 写操作 |
|---|---|---|
| 超时 | 必须 | 必须 |
| 重试 | 可自动重试 | 需幂等键 |
| 隔离 | try-catch 包裹 | try-catch + 事务 |
| 审计 | 可选 | 必须记录 |
5. 常见问题与排查技巧实录
5.1 Agent 不调工具或调错工具怎么办
这是最高频的问题。模型明明有工具可用,却选择直接回答,或者调了一个不相关的工具。排查思路按优先级来。
先看工具描述。描述是不是太模糊?是不是没写清楚“什么时候用”?我遇到过一次,工具叫search,描述写“搜索信息”,模型经常不用它,因为它不知道搜什么、什么时候该搜。改成“当用户询问你不确定的事实性问题时,用此工具搜索知识库”之后,调用率立刻上来了。
再看工具数量。工具太多(超过 20 个)模型会挑花眼,准确率下降。解法是分组或分层:先让模型选类别,再在类别里选具体工具。或者用检索的方式,根据当前输入动态注入最相关的几个工具,而不是全量塞进去。
最后看系统提示。有没有明确告诉模型“你有工具可用,遇到 X 情况必须用工具”?模型不会自己意识到工具的存在,提示里得点明。我习惯在系统提示里加一段:“你可以使用以下工具来完成任务。当任务需要外部信息或操作时,优先使用工具而不是凭记忆回答。”
5.2 上下文爆炸与成本失控
跑着跑着上下文越来越长,成本飙升,这是 agent-native 应用的慢性病。根因是历史消息无节制地累积。解法是上下文预算管理:给上下文设一个 token 上限,超了就触发压缩。
压缩策略我一般用组合拳:最近 N 轮保留原文,更早的做摘要,工具返回的大块数据只保留关键字段。摘要用模型生成,提示写“用三句话总结以下对话的关键信息和结论”。工具结果压缩更简单,只留ok和关键数据,把冗余的元信息砍掉。
还有一个省钱技巧:不是每步都要用最强的模型。规划步骤用强模型,工具参数填充、结果摘要用便宜的小模型。这个分层策略能省不少钱,效果损失很小。我实测过一个项目,分层之后成本降了六成,任务成功率只降了两个百分点。
5.3 执行中断后状态不一致
用户刷新页面、网络断开、服务重启,都可能导致 Agent 执行中断。恢复时如果状态不一致,会出现重复执行、丢失步骤等问题。排查这个问题的关键是确认状态的持久化时机和粒度。
我的做法是每一步执行完就持久化一次,而不是整个任务结束才存。持久化的内容包括:当前步数、消息历史、待执行的工具调用、状态标记。恢复时从最后持久化的状态继续。这里有个细节:如果中断发生在“工具已执行但结果未持久化”的窗口,恢复后会重复执行工具。所以写操作工具必须有幂等键,读操作工具无所谓。
排查时我会加日志,记录每次状态持久化的时间点和内容摘要。出问题时对比日志,就能定位是哪个环节的状态没对上。这个日志在开发期很有用,上线后可以降级为采样记录。
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 恢复后重复执行 | 工具结果未持久化 | 查持久化日志 | 写操作加幂等键 |
| 恢复后丢步骤 | 持久化粒度太粗 | 查状态快照 | 改为每步持久化 |
| 恢复后状态错乱 | 事件与状态顺序反了 | 查推送与存储顺序 | 先存后推 |
| 恢复后卡住 | 状态标记未更新 | 查 status 字段 | 补全状态机 |
5.4 模型输出格式不稳定
Agent 依赖模型输出结构化数据(工具调用、参数),但模型偶尔会输出格式不对的内容,比如多了 markdown 代码块包裹、JSON 里带注释、字段名拼错。这个问题的解法是双重保险:提示里明确要求格式,代码里做容错解析。
提示层面,明确写“只输出 JSON,不要任何额外文字”。代码层面,解析失败时不要直接崩,而是尝试修复:去掉代码块标记、去掉尾随逗号、用宽松的解析器。修复失败再把错误信息返回给模型,让它重新输出。我一般给模型两次重试机会,两次都失败就降级处理或报错。
还有一个技巧:用模型的“结构化输出”能力。现在很多模型支持强制 JSON schema 输出,直接约束输出格式,比靠提示词可靠得多。如果你的模型支持,一定要用上,能省掉大量解析容错的代码。
6. 我在这条路上踩过的几个真实坑
第一个坑是过早抽象。项目刚开始,我就想设计一套“通用 Agent 框架”,结果抽象层写了一堆,真正跑起来发现每个 Agent 的需求都不一样,抽象根本套不住。后来学乖了,先写具体的、能跑的 Agent,跑通三个之后再回头看共性,这时候抽象才靠谱。先具体后抽象,别反过来。
第二个坑是忽视可观测性。Agent 执行是个黑盒,出了问题不知道哪一步错了。我早期没加日志和追踪,一个 bug 排查了一整天。后来给每个事件加了 traceId,每一步的输入输出都记下来,排查效率天差地别。agent-native 应用的可观测性不是可选项,是必需品。
第三个坑是把 Agent 当万能。有些任务明明用确定性代码几行就能搞定,非要让 Agent 去推理,结果又慢又不稳。比如格式转换、固定规则的计算,这些用普通函数就好。Agent 应该用在真正需要“判断”和“规划”的地方。能用代码解决的,别用模型,这是成本和稳定性的双重考量。
第四个坑是忽略用户预期管理。Agent 执行慢,用户不知道在干什么就容易焦虑。加了流式输出和进度提示之后,同样的等待时间,用户感受完全不同。这个不是技术问题,是产品问题,但技术实现要配合。别让用户面对一个转圈的加载图标超过三秒,这是底线。
7. 后续可以怎么扩展这套东西
跑通最小执行体之后,往上加能力的方向有几个。多 Agent 协作:把复杂任务拆给多个专职 Agent,一个负责规划、一个负责执行、一个负责校验,通过消息传递协作。这个方向能力上限高,但协调成本也高,建议单 Agent 跑稳了再上。工具生态:把常用工具标准化、可插拔,新项目直接复用,减少重复造轮子。评估体系:给 Agent 建一套自动化测试,用固定输入跑,看输出是否符合预期,防止改一处崩一片。
我个人最看重的扩展方向是评估。Agent 的不确定性让传统单元测试很难写,但没有评估就没有迭代的底气。我的做法是攒一批真实场景的输入输出对,每次改动跑一遍,看成功率有没有下降。这个投入前期看着重,长期回报很高。
最后分享一个小技巧:给 Agent 加一个“解释模式”。执行的时候除了产出结果,还让它输出一段“我为什么这么做”的说明。这个说明对调试极有价值,对用户也友好——用户能看到 Agent 的推理过程,信任感会强很多。实现上就是在系统提示里加一句“在给出最终答案前,简要说明你的推理步骤”,成本很低,收益很高。