1. 为什么“agent-native”值得单独拎出来聊第一次看到“agent-native”这个词很多人会下意识把它归到“又一个前端框架”或者“又一个 AI 套壳库”里。我一开始也这么想直到真正把一个带工具调用、带多轮状态、带流式输出的智能体应用从零搭起来才发现问题根本不在“框架好不好用”而在于整个应用的骨架是不是按智能体的思维方式设计的。传统应用的核心是“请求—处理—响应”一次交互基本就结束了。而 agent-native 应用的核心是“目标—规划—行动—观察—再规划”它是一个持续循环、状态不断累积的过程。这两者的差别就像自动售货机和便利店店员的差别前者你投币选货它吐出来就完事后者你说“帮我配一份适合感冒时吃的清淡晚餐”他会问你几个人吃、有没有忌口、家里有什么厨具然后一边做一边根据你的反馈调整。“agent-native”这个词里的 native不是指编译成机器码那种 native而是指应用从设计之初就把智能体当作一等公民。TypeScript 在这里扮演的角色非常关键因为智能体的工具调用、消息结构、状态流转全都是高度结构化的数据没有类型系统兜底跑到第三轮对话就会出现“工具参数对不上”“消息角色错乱”这类问题而且极难排查。这篇文章适合三类人看一是已经用 TypeScript 写过普通 Web 应用想往智能体方向转的开发者二是正在评估要不要自己搭一套 agent 框架而不是直接用现成方案的团队技术负责人三是对 agentic apps 感兴趣但被各种概念绕晕想找一个能落地的切入点的学习者。我会把设计思路、核心数据结构、工具注册机制、状态管理、流式处理、常见坑都拆开讲代码能直接抄。2. agent-native 应用的整体设计与思路拆解2.1 从“函数调用”到“智能体循环”的思维转变普通应用里你写一个函数输入参数返回结果调用方拿到结果继续往下走。智能体应用里你写的是一个工具它被注册到一个工具表里然后由模型在运行时决定要不要调用、什么时候调用、传什么参数。这个决定过程不是你能完全预测的所以你的代码必须能处理“模型选择了一个你没预料到的工具组合”这种情况。我见过不少项目一开始把工具调用写成硬编码的 if-else比如“如果用户问天气就调天气接口如果问时间就调时间接口”。这种做法在 demo 阶段没问题但一旦工具数量超过五个或者用户的问题稍微绕一点整个逻辑就会变成一团乱麻。agent-native 的思路是把决策权交给模型把执行权和边界控制留给自己。具体来说模型负责“想做什么”你的代码负责“能不能做”和“怎么做”。比如模型说“我要调用 deleteFile 工具删除 /etc/passwd”你的工具执行层必须有能力拒绝这个调用而不是傻乎乎地执行。这就是 agent-native 和普通函数调用的本质区别你是在和一个有自主决策能力的组件协作而不是在调用一个确定性函数。2.2 为什么选 TypeScript 而不是 PythonPython 在 AI 领域生态确实好但 agent-native 应用往往不只是跑一个模型推理它还要处理 HTTP 请求、管理前端状态、做流式传输、和数据库打交道。这些场景下 TypeScript 的优势非常明显。第一类型系统能覆盖从模型输出到工具参数的完整链路。你可以定义一个 ToolDefinition 类型规定每个工具必须有 name、description、parameters schema 和 execute 函数。模型返回的 tool_call 经过解析后TypeScript 能在编译期就告诉你“这个工具的 required 参数少了一个”而不是等到运行时才报错。第二前后端同构。智能体应用经常需要把中间状态推送到前端展示比如“正在调用搜索工具”“正在整理结果”。用 TypeScript 的话前后端可以共享同一套消息类型定义前端拿到数据直接渲染不需要手动对齐字段。第三流式处理更自然。Node.js 的 Stream API 配合 TypeScript 的异步迭代器处理模型输出的 token 流非常顺手。你可以用 for await 循环逐块读取边读边推给前端用户体验比等完整响应再渲染好得多。当然如果你的智能体需要跑本地大模型或者做复杂的数值计算Python 可能更合适。但如果是做面向用户的 agentic appsTypeScript 是更稳妥的选择。2.3 核心架构分层别把鸡蛋放在一个篮子里我习惯把 agent-native 应用分成四层每层职责清晰方便替换和测试。层级职责关键模块接入层接收用户输入管理会话HTTP 路由、会话 ID 生成、鉴权编排层驱动智能体循环管理消息历史AgentRunner、消息队列、循环控制能力层注册和执行工具处理外部调用ToolRegistry、工具执行器、超时控制模型层与模型交互解析输出ModelClient、流式解析、重试逻辑这样分层的好处是你想换模型供应商只动模型层想加新工具只动能力层想改交互方式只动接入层。我见过把所有这些揉在一个文件里的项目改一行代码要重新理解三百行逻辑维护成本极高。注意编排层不要直接调用具体工具而是通过 ToolRegistry 查找。这样你可以在测试时注册 mock 工具不需要真的去调外部 API。3. 核心细节解析与实操要点3.1 消息结构设计智能体的“记忆”长什么样智能体的消息历史是它的短期记忆设计不好会出现“聊了三轮就忘了自己刚才说过什么”的情况。我推荐用 discriminated union 来定义消息类型这样 TypeScript 能帮你做类型收窄。type Message | { role: system; content: string } | { role: user; content: string } | { role: assistant; content: string; toolCalls?: ToolCall[] } | { role: tool; toolCallId: string; content: string };这里有几个细节值得展开。第一assistant 消息可能同时包含文本内容和工具调用不要假设它们互斥。模型经常一边说“我来查一下”一边发起工具调用。第二tool 消息必须带 toolCallId否则模型不知道这个结果对应哪个调用。第三system 消息不要每次循环都重新插入否则会打乱消息顺序有些模型对消息顺序很敏感。我踩过的一个坑是把工具执行结果直接拼成字符串塞进 user 消息里。这样做短期能跑但模型会分不清“这是用户说的”还是“这是工具返回的”导致它在后续回复里把工具结果当成用户观点来引用。正确做法是用 role: tool 单独标记。3.2 工具注册机制让模型知道有什么可用工具注册的核心是描述要写给人看schema 要写给机器看。description 字段是模型判断“要不要用这个工具”的主要依据写得含糊模型就不会用写得夸张模型会滥用。interface ToolDefinition { name: string; description: string; parameters: { type: object; properties: Recordstring, unknown; required: string[]; }; execute: (args: Recordstring, unknown) Promisestring; }description 的写法我总结了一个模板动词 对象 适用场景 不适用场景。比如“查询指定城市的当前天气。适用于用户询问实时天气、温度、降水情况。不适用于查询历史天气或未来预报。”这样模型能清楚知道边界在哪。parameters 用 JSON Schema 描述required 数组一定要准确。我见过把可选参数写进 required 的结果模型每次都必须编一个值出来反而降低了准确性。工具执行函数返回字符串不要返回对象。因为模型只能理解文本你返回对象它还得再解析一遍不如在 execute 里就格式化成可读文本。如果返回内容很长考虑截断或摘要否则会占用大量上下文窗口。3.3 循环控制什么时候停什么时候继续智能体循环最怕两件事一是死循环模型反复调用同一个工具二是提前退出模型还没完成任务就停了。我的做法是设置三重保险。第一重是最大轮次限制比如 10 轮。超过就强制停止把当前结果返回给用户并附上“任务可能未完成”的提示。第二重是重复调用检测如果连续两次调用的工具名和参数完全一样就中断循环因为这大概率是模型卡住了。第三重是无工具调用退出如果模型返回的 assistant 消息里没有 toolCalls说明它认为可以直接回复用户了循环自然结束。async function runAgentLoop(messages: Message[], maxTurns 10) { let turn 0; let lastToolSignature ; while (turn maxTurns) { const response await callModel(messages); messages.push(response); if (!response.toolCalls?.length) break; const signature JSON.stringify(response.toolCalls); if (signature lastToolSignature) break; lastToolSignature signature; for (const call of response.toolCalls) { const result await executeTool(call); messages.push({ role: tool, toolCallId: call.id, content: result }); } turn; } return messages; }这段代码看起来简单但每一行都有讲究。maxTurns 不要设太大否则用户等太久也不要设太小否则复杂任务做不完。我一般设 8 到 12 之间根据工具的平均执行时间调整。重复检测用 JSON.stringify 比较注意参数顺序可能不同严格来说应该做规范化排序但实际用下来直接比较也能覆盖大部分情况。提示如果工具执行时间较长考虑加一个总超时时间比如 60 秒。超过就中断避免用户一直等。4. 实操过程与核心环节实现4.1 环境搭建与依赖选择先初始化项目用 TypeScript 的严格模式。我习惯用 tsx 做开发时的运行器比 ts-node 快配置也简单。mkdir agent-native-demo cd agent-native-demo npm init -y npm install typescript tsx types/node npx tsc --init --strict --target ES2022 --module NodeNext --moduleResolution NodeNexttsconfig 里我建议开启 noUncheckedIndexedAccess这样访问数组元素时 TypeScript 会提醒你可能 undefined能避免不少运行时错误。另外 exactOptionalPropertyTypes 也建议开工具参数的可选字段处理会更严谨。模型客户端我选的是官方 SDK因为它对工具调用的支持最完整流式解析也稳定。如果你要用多个供应商可以抽象一个 ModelClient 接口但初期不要过度设计先把一个跑通。4.2 实现一个可用的工具以“查询天气”为例工具实现要处理三件事参数校验、外部调用、结果格式化。参数校验不要省模型传过来的参数不一定符合你的预期。const weatherTool: ToolDefinition { name: get_weather, description: 查询指定城市的当前天气。适用于用户询问实时天气、温度、降水。不适用于历史天气或预报。, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认摄氏度 }, }, required: [city], }, async execute(args) { const city args.city; if (typeof city ! string || city.length 0) { return 错误城市名称不能为空; } const unit args.unit fahrenheit ? fahrenheit : celsius; try { const data await fetchWeather(city, unit); return ${city}当前天气${data.condition}温度${data.temp}${unit celsius ? °C : °F}湿度${data.humidity}%; } catch (err) { return 查询${city}天气失败${(err as Error).message}; } }, };注意 execute 里不要抛异常而是返回错误字符串。因为抛异常会中断整个循环而返回错误字符串模型能看到它可能会换个城市再试或者告诉用户查询失败。这是 agent-native 应用和普通应用的一个重要区别错误也是信息要让模型有机会处理。4.3 工具注册表与执行器工具注册表就是一个 Map但要在注册时做重名检查执行时做超时控制。class ToolRegistry { private tools new Mapstring, ToolDefinition(); register(tool: ToolDefinition) { if (this.tools.has(tool.name)) { throw new Error(工具 ${tool.name} 已注册); } this.tools.set(tool.name, tool); } get(name: string) { return this.tools.get(name); } list() { return Array.from(this.tools.values()).map(({ name, description, parameters }) ({ name, description, parameters, })); } async execute(call: ToolCall, timeoutMs 15000) { const tool this.tools.get(call.name); if (!tool) return 错误未找到工具 ${call.name}; const args JSON.parse(call.arguments || {}); const timeout new Promisestring((resolve) setTimeout(() resolve(错误工具 ${call.name} 执行超时), timeoutMs) ); return Promise.race([tool.execute(args), timeout]); } }超时控制用 Promise.race 实现简单有效。注意 JSON.parse 可能抛异常实际代码里要 try-catch。工具列表传给模型时只传 name、description、parameters不要传 execute 函数否则序列化会出问题。4.4 流式输出与前端对接流式输出不只是为了好看它能让用户感知到智能体在“思考”。实现上模型返回的流里会混合文本增量和工具调用增量需要分别处理。async function streamAgent(messages: Message[], onEvent: (e: AgentEvent) void) { const stream await modelClient.chatStream({ messages, tools: registry.list() }); let currentToolCall: PartialToolCall | null null; for await (const chunk of stream) { if (chunk.type text) { onEvent({ type: text, content: chunk.content }); } else if (chunk.type tool_call_start) { currentToolCall { id: chunk.id, name: chunk.name, arguments: }; } else if (chunk.type tool_call_delta) { if (currentToolCall) currentToolCall.arguments chunk.delta; } else if (chunk.type tool_call_end) { if (currentToolCall) { onEvent({ type: tool_call, call: currentToolCall as ToolCall }); currentToolCall null; } } } }前端拿到 text 事件就追加到气泡里拿到 tool_call 事件就显示“正在调用 XX 工具”。这样用户不会觉得界面卡住了。注意工具调用的 arguments 是分块传输的要等 tool_call_end 才能解析提前解析会得到不完整的 JSON。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。原因通常有三个工具描述太模糊、参数 schema 有问题、system prompt 没引导。排查顺序是先看工具描述确保它清楚说明了“什么时候用”。然后检查 parameters 的 required 是否合理如果所有参数都是可选的模型可能觉得不传也行。最后在 system prompt 里加一句“你可以使用提供的工具来获取实时信息”有时候模型只是不知道它可以用。我遇到过一个案例工具描述写的是“获取数据”模型完全不用。改成“查询指定城市的实时天气数据包括温度、湿度、天气状况”之后调用率立刻上来了。描述要具体到模型能判断“用户问天气时我该用这个”。5.2 工具参数解析失败模型返回的 arguments 偶尔会是空字符串或者不完整 JSON。处理方式是先 try-catch 解析失败就返回错误信息给模型让它重新生成。不要直接崩溃。let args: Recordstring, unknown; try { args JSON.parse(call.arguments || {}); } catch { return 错误工具参数解析失败请检查参数格式; }另外如果模型传了 schema 里没定义的参数不要直接忽略可以在返回结果里提一句“忽略了未知参数 X”这样模型下次会注意。5.3 循环卡住或重复调用前面提到的重复检测能解决大部分情况但有时候模型会换一个参数反复调用同一个工具比如不断调整城市名。这种情况可以在工具执行器里加一个调用计数同一个工具超过 3 次就返回“该工具调用次数已达上限请尝试其他方式或直接回复用户”。还有一个隐蔽的坑是工具返回的内容太长模型每次都要重新读一遍导致它“忘记”之前已经查过了。解决办法是在工具返回结果里加一个简短摘要比如“已查询北京天气晴25°C”而不是返回一大段 JSON。5.4 常见问题速查表现象可能原因解决方向模型不调用工具描述模糊、schema 有问题改描述检查 required参数解析失败模型输出不完整 JSONtry-catch返回错误让模型重试循环卡住重复调用同一工具加重复检测和调用计数工具执行超时外部 API 慢加超时返回错误信息上下文溢出消息历史太长截断旧消息或做摘要前端不更新流式事件没处理检查事件类型和推送逻辑注意上下文溢出是个容易被忽视的问题。消息历史超过模型窗口后要么截断最早的几条要么用模型做一次摘要。我一般保留 system 消息和最近 10 轮对话更早的做摘要压缩。6. 从能跑到好用几个提升体验的细节6.1 给工具加“思考”提示模型在调用工具前如果能先输出一句“我来查一下天气”用户体验会好很多。实现方式是在 system prompt 里加一句“在调用工具前先用一句话说明你要做什么”。这样流式输出时用户先看到文字再看到工具调用感知上更自然。但要注意有些模型会因此变得啰嗦每轮都说一堆。可以加一个限制“说明不超过 20 字”。这个度需要根据实际效果调。6.2 工具结果的格式化工具返回给模型的内容格式要统一。我习惯用“状态成功/失败 关键信息”的格式。比如“状态成功。北京当前天气晴25°C湿度 40%。”这样模型容易解析也容易在回复里引用。如果工具返回的是列表用换行分隔不要用 JSON 数组。模型对换行分隔的文本理解更好。比如“找到 3 条结果\n1. ...\n2. ...\n3. ...”。6.3 错误处理的分级不是所有错误都要让模型知道。比如网络抖动导致的临时失败可以在工具内部重试一次重试成功就不告诉模型。但如果是参数错误或者权限问题必须返回给模型让它决定是换个方式还是告诉用户。我一般分三级可重试错误网络超时内部重试可恢复错误参数不对返回给模型不可恢复错误工具不存在直接中断并记录日志。6.4 日志与可观测性agent-native 应用的调试比普通应用难因为决策过程在模型那边。我的做法是记录每一轮的完整消息历史、模型原始输出、工具调用参数和结果。用一个简单的 JSONL 文件存下来出问题时可以回放。function logTurn(turn: number, messages: Message[], response: unknown) { const entry { turn, timestamp: Date.now(), messages, response }; fs.appendFileSync(agent.log.jsonl, JSON.stringify(entry) \n); }不要只记最终结果中间过程才是排查问题的关键。我遇到过模型在第三轮突然改变策略的情况没有中间日志根本看不出原因。7. 关于 agent-native 的一些个人体会搭过几个 agent-native 应用之后我最大的感受是难点不在技术而在预期管理。传统应用你输入 A 就得到 B测试用例写起来很明确。智能体应用你输入 A它可能调工具可能直接回答可能调了工具又不用结果。这种不确定性让测试和验收变得很麻烦。我的应对方式是把“智能体行为”和“工具行为”分开测试。工具行为是确定性的可以写单元测试。智能体行为用一组典型场景做端到端测试但不追求每次输出完全一致而是检查关键要素是否出现比如“是否调用了天气工具”“最终回复是否包含温度”。另一个体会是工具数量不要贪多。我一开始注册了十几个工具结果模型经常选错。后来精简到五个核心工具准确率明显提升。工具多了描述之间的边界就模糊模型容易混淆。宁可让一个工具多做一点也不要拆得太细。最后分享一个小技巧在开发阶段给每个工具加一个 mock 模式返回固定数据。这样你调智能体逻辑时不用等外部 API迭代速度能快好几倍。等逻辑稳定了再切到真实工具。这个习惯帮我省了大量等待时间。