ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent-Native 架构实战:TypeScript 工具契约与智能体循环设计

Agent-Native 架构实战:TypeScript 工具契约与智能体循环设计 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 解析工具解析出来发现是扫描件于是调用 OCROCR 结果里发现关键条款于是调用比对工具。这条路径你事先根本写不出来因为它是根据中间结果动态生成的。这个差异带来的第一个工程后果是你不能再依赖穷举所有分支来保证正确性。你必须设计一套机制让智能体在遇到没见过的分支时能做出合理决策或者至少能安全地停下来求助。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.infertypeof 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 这个方向还在快速演进今天的最佳实践可能半年后就过时了。但有些底层原则是稳定的清晰的工具契约、分层的状态管理、健壮的错误恢复、完善的可观测性。把这些打扎实无论上层框架怎么变你都能快速适应。
返回列表