尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

十万星标AI Agent项目:LLM不可靠依赖的工程实践与TypeScript设计

十万星标AI Agent项目:LLM不可靠依赖的工程实践与TypeScript设计 1. 十万星标背后真正值钱的不是AI功能第一次打开这个项目的源码目录时我的反应是有点失望的。没有想象中的复杂架构图没有炫技式的设计模式堆砌目录结构干净得甚至有些朴素。但当我花了两天时间把核心模块逐个读完才意识到一个事实这个项目能拿到十万星标靠的根本不是AI Agent这个标签本身而是它把软件工程里那些最基础、最容易被忽视的东西做到了近乎偏执的程度。大多数人看到AI Agent项目这几个字第一反应是去看它怎么调模型、怎么设计Prompt、怎么实现工具调用。这些当然重要但如果你只盯着这些看就会错过真正有价值的部分。我见过太多团队花三个月搭出来的Agent框架功能列表写满两页纸结果上线两周就因为状态管理混乱、错误处理缺失、配置散落各处而推倒重来。问题从来不出在AI上出在工程上。这个项目给我的最大冲击在于它把LLM当成一个不可靠的外部依赖来对待而不是当成一个聪明的大脑。这个认知差异直接决定了整个代码库的组织方式。当你把LLM视为不可靠依赖时你会自然而然地去做超时控制、重试策略、降级方案、结果校验、成本追踪——这些全是传统后端工程里烂熟于心的东西。但很多Agent项目偏偏不做因为它们潜意识里觉得模型很聪明应该没问题。这篇文章我想聊的不是如何搭建一个AI Agent而是从这个项目的代码里我提炼出的那些可以迁移到任何软件项目里的工程决策。无论你做的是Agent、是CLI工具、还是普通的Web服务这些经验都成立。关键词里的TypeScript、CLI、LLM我会结合具体代码场景来讲但重点始终放在为什么这样设计上。适合谁看如果你已经写过一些Agent相关的代码但总觉得自己的项目能跑但不敢改那这篇就是写给你的。如果你还没接触过Agent开发但想了解一个成熟项目是怎么组织代码的同样能有所收获。2. 把LLM当作不可靠依赖是整个架构的地基2.1 一个反直觉的设计所有模型调用都必须可中断项目里有一个让我印象深刻的细节每一个LLM调用都被包裹在一个可取消的上下文里。不是简单的Promise.race超时而是从调用发起到结果返回整条链路上的每一层都支持中断信号传递。为什么这件事重要因为LLM调用的延迟是不可预测的。同一个Prompt可能两秒返回也可能卡三十秒。如果用户在这期间想取消操作或者系统检测到某个条件需要提前终止你的代码必须能干净地停下来。我见过太多项目用简单的await fetch()就完事了结果用户点了取消按钮后台请求还在跑token还在烧状态还停在处理中。这个项目用的是TypeScript里的AbortController模式但它的精妙之处在于中断信号的传播层级。从最外层的用户命令到中间的Agent调度器再到具体的工具执行器最后到HTTP请求层每一层都接收并转发这个信号。任何一层收到中断都会触发清理逻辑取消未完成的子任务、释放占用的资源、把状态回滚到一致的位置。// 简化的中断传播示意 async function executeAgentTask( task: Task, signal: AbortSignal ): PromiseTaskResult { const controller new AbortController(); const combinedSignal combineSignals(signal, controller.signal); try { const plan await planTask(task, combinedSignal); const results await Promise.all( plan.steps.map(step executeStep(step, combinedSignal)) ); return aggregateResults(results); } catch (err) { if (isAbortError(err)) { await cleanupPartialState(task.id); throw new TaskCancelledError(task.id); } throw err; } }这段代码看起来平平无奇但关键在于cleanupPartialState这个调用。很多项目的中断处理只是停止等待但已经产生的副作用比如已经写入的文件、已经发送的请求、已经扣减的配额没有被清理。这个项目把中断当成一个一等公民的状态来处理而不是一个异常分支。2.2 重试不是万能药区分错误类型才是关于重试我见过两种极端。一种是完全不重试一次失败就报错给用户另一种是无脑重试三次不管什么错误都重试。这个项目走的是中间路线而且做得非常细致。它把LLM调用可能遇到的错误分成了几类错误类型典型表现处理策略网络瞬时错误连接超时、DNS解析失败指数退避重试最多3次速率限制429状态码读取Retry-After头按指示等待模型拒绝内容策略拦截不重试直接返回用户可读的说明上下文超长token超限不重试触发上下文压缩流程服务端错误500/502/503有限重试同时切换备用端点这个分类表看起来简单但真正落地的时候很多团队根本分不清模型拒绝和网络错误的区别导致要么该重试的没重试要么不该重试的疯狂重试浪费成本。提示判断一个错误是否值得重试核心标准是重试后结果是否会不同。如果错误原因是确定性的比如输入超长、内容被拦截重试一万次结果都一样只会浪费时间和金钱。2.3 成本追踪不是财务需求是工程需求项目里有一个独立的成本追踪模块记录每次LLM调用的token消耗和预估费用。一开始我觉得这是给财务看的后来发现它的真正价值在工程侧。当你有了精确的成本数据你才能回答这些问题这个功能的平均成本是多少哪个环节是成本大头上下文压缩策略到底省了多少缓存命中率对成本的影响有多大没有数据所有的优化都是拍脑袋。这个模块的设计也很克制它不依赖任何外部服务就是把每次调用的元数据写到一个本地队列里定期聚合。数据结构大概是这样interface LLMCallRecord { timestamp: number; model: string; promptTokens: number; completionTokens: number; cachedTokens: number; latencyMs: number; taskId: string; stepName: string; estimatedCost: number; }注意taskId和stepName这两个字段。它们让成本数据可以和具体的业务逻辑关联起来。你可以查某个用户的某次会话花了多少钱也可以查规划步骤和执行步骤哪个更烧钱。这种可观测性是后期优化的前提。3. CLI工具的交互设计藏着对用户心智的深刻理解3.1 为什么这个项目选择CLI而不是Web界面这个问题我想了很久。一个十万星标的AI Agent项目完全有能力做一个漂亮的Web界面但它偏偏选择了CLI作为主要交互方式。这不是技术限制是刻意的设计选择。CLI的优势在于可组合性。一个设计良好的CLI工具可以管道串联、可以脚本调用、可以集成到任何自动化流程里。而Web界面本质上是一个封闭的沙盒你只能在它提供的框架内操作。对于开发者工具来说可组合性比视觉美观重要得多。但CLI的挑战也很明显没有图形提示用户怎么知道当前在做什么怎么知道有哪些命令可用怎么在长任务执行过程中获得反馈这个项目在交互反馈上下了很大功夫。3.2 流式输出不只是打字机效果很多人以为流式输出就是为了好看让用户看到文字一个个蹦出来。这个项目对流式输出的理解更深一层流式输出是让用户在第一时间判断方向对不对的手段。当Agent开始执行一个复杂任务时它会先输出一个简短的规划摘要然后逐步输出每一步的执行情况。用户看到前三步就知道这个方向对不对如果不对可以立刻中断不用等整个任务跑完。这比转圈圈等三十秒然后告诉你结果错了的体验好太多。实现上它用的是一个事件流模型。Agent的每个动作都会发出一个结构化事件CLI层负责把这些事件渲染成人类可读的输出。事件类型包括plan.created规划完成包含步骤列表step.started某一步开始执行step.progress执行中的进度更新step.completed某一步完成包含结果摘要step.failed某一步失败包含错误信息task.completed整个任务完成这种事件驱动的设计让CLI层和Agent核心完全解耦。将来如果要加一个Web界面或者IDE插件只需要换一个事件消费者就行核心逻辑一行不用改。3.3 错误信息的可操作性别只说出错了我特别想聊一下这个项目的错误信息设计。大多数CLI工具报错就是一行红字告诉你Error: something went wrong。用户看完一脸懵不知道是自己操作错了还是系统出问题了还是网络不通。这个项目的错误信息遵循一个模板发生了什么 为什么发生 你可以怎么做。举个例子错误无法连接到模型服务 原因请求超时30秒内未收到响应 建议 1. 检查网络连接是否正常 2. 如果使用自定义端点确认地址配置正确 3. 可以尝试增加超时时间--timeout 60 4. 如果问题持续运行 doctor 命令进行诊断这种错误信息的设计成本很低但对用户体验的提升是巨大的。它把用户从猜谜游戏里解放出来直接告诉下一步该干什么。注意错误信息里不要暴露内部实现细节比如堆栈跟踪、内部变量名但也不要过度抽象到用户看不懂。好的错误信息是翻译过的技术信息既准确又易懂。4. TypeScript在这个项目里的真实价值4.1 类型系统不是为了看起来专业我见过一些项目用TypeScript只是为了在简历上写一笔实际代码里全是any和as。这个项目不一样它的类型定义是真正在约束业务逻辑的。举个具体的例子。Agent执行任务时每一步的状态是有限集合pending、running、completed、failed、cancelled。项目用联合类型来定义这个状态而不是用字符串或者枚举type StepStatus | { type: pending } | { type: running; startedAt: number } | { type: completed; result: StepResult; completedAt: number } | { type: failed; error: StepError; failedAt: number } | { type: cancelled; reason: string; cancelledAt: number };这种可辨识联合的写法让编译器能在你访问result字段时自动检查你是否已经处理了completed状态。如果你忘了处理某个分支TypeScript会直接报错。这比运行时才发现哦原来这个状态下没有result字段要早得多。4.2 配置管理的类型安全从源头杜绝拼写错误项目的配置系统也值得一提。它没有用简单的JSON配置文件加运行时校验而是用TypeScript类型来定义配置结构然后在加载时做一次完整的校验和默认值填充。interface AgentConfig { model: { provider: openai | anthropic | local; name: string; maxTokens: number; temperature: number; }; execution: { maxSteps: number; timeoutMs: number; retryPolicy: RetryPolicy; }; tools: { enabled: string[]; sandbox: boolean; }; }这样做的好处是配置文件里的任何拼写错误、类型错误、缺失字段都会在启动时被立刻发现而不是等到某个功能被触发时才报错。对于CLI工具来说启动时的快速失败比运行时的神秘崩溃要好得多。4.3 泛型在工具系统里的巧妙应用工具系统是Agent项目的核心之一。每个工具都有输入和输出输入需要校验输出需要序列化。这个项目用泛型来保证工具定义的一致性interface ToolTInput, TOutput { name: string; description: string; inputSchema: SchemaTInput; execute(input: TInput, context: ToolContext): PromiseTOutput; }当你在注册工具时TypeScript会自动推断输入输出的类型。如果你在execute里返回了不符合TOutput的值编译器会立刻报错。这种设计让新增工具变得非常安全你不需要记住任何隐式约定类型系统会帮你检查。5. 从代码组织看可维护性的真正含义5.1 目录结构反映的是依赖关系不是功能分类很多项目的目录结构是按功能分的utils/、services/、models/、controllers/。这种分法看起来整齐但有一个致命问题它不反映模块之间的依赖关系。你打开一个文件不知道它依赖谁也不知道谁依赖它。这个项目的目录结构是按层次分的src/ core/ # 核心抽象不依赖任何具体实现 domain/ # 业务领域逻辑依赖core infrastructure/# 具体实现LLM客户端、文件系统、网络 application/ # 应用层编排domain和infrastructure cli/ # 用户界面层依赖application这个结构的核心原则是依赖只能从上往下不能从下往上。core层不知道infrastructure的存在domain层不知道cli的存在。这种单向依赖让每一层都可以独立测试、独立替换。我特别欣赏它对core层的克制。这个层里只有接口和纯函数没有任何I/O操作没有任何外部依赖。这意味着core层的测试不需要mock任何东西跑起来飞快。5.2 错误处理的分层策略错误处理是最能看出一个项目工程水平的维度。这个项目的错误处理分了三层第一层是基础设施层负责把各种底层错误网络错误、文件系统错误、API错误转换成项目内部统一的错误类型。这一层的目标是不让底层细节泄漏到上层。第二层是领域层负责根据业务语义对错误进行分类和包装。比如模型返回了无效的JSON在基础设施层是一个解析错误在领域层会被包装成InvalidModelResponseError并附带原始响应内容供调试。第三层是应用层负责决定错误的最终呈现方式。是重试、是降级、是提示用户、还是直接崩溃都在这一层决定。这种分层的好处是每一层只需要关心自己职责范围内的错误处理逻辑不需要知道其他层的细节。当你要修改错误提示文案时只需要改应用层当你要增加一种新的底层错误类型时只需要改基础设施层。5.3 测试策略不追求覆盖率追求关键路径这个项目的测试文件不多但每一个都打在关键路径上。它没有追求100%的覆盖率而是把测试资源集中在最容易出问题的地方状态机转换Agent执行过程中的状态流转每个转换都有测试错误恢复模拟各种错误场景验证恢复逻辑是否正确边界条件空输入、超长输入、特殊字符输入并发安全多个任务同时执行时的资源竞争相比之下那些为了覆盖率而写的测试——比如测试一个getter函数返回了正确的值——在这个项目里几乎看不到。这种务实的测试策略让测试套件的维护成本很低同时又能真正抓住回归问题。提示判断一个测试是否有价值问自己一个问题如果这个测试失败了我会不会紧张如果答案是不会那这个测试可能不值得写。6. 那些文档里不会写的踩坑经验6.1 上下文窗口管理不是越大越好项目早期版本有一个设计把所有历史对话都塞进上下文让模型自己判断哪些相关。结果很快遇到了问题token消耗爆炸响应变慢而且模型在超长上下文里的表现反而下降。后来的方案是分层上下文管理。把上下文分成几个层次系统层固定的系统提示和工具定义永远保留任务层当前任务的规划和进度任务期间保留会话层最近的几轮对话按时间窗口保留检索层根据当前查询动态检索的相关历史按需加载每一层有不同的保留策略和压缩策略。系统层不压缩任务层在任务完成后归档会话层超过窗口就丢弃检索层按相关性排序后截断。这个方案的核心洞察是上下文不是越多越好而是越相关越好。与其塞一堆可能相关的信息让模型自己筛不如在工程侧做好筛选只给模型最相关的内容。6.2 工具调用的幂等性一个容易被忽视的细节Agent在执行任务时可能会因为重试、中断恢复等原因多次调用同一个工具。如果工具不是幂等的就会产生重复副作用。比如发送邮件这个工具被调用了两次用户就收到了两封邮件。这个项目要求所有工具都必须声明自己的幂等性并且对于非幂等的工具框架层会自动生成一个幂等键在重试时复用。工具实现者可以选择使用这个幂等键来做去重。interface ToolDefinition { name: string; idempotent: boolean; execute(input: unknown, context: ToolContext): Promiseunknown; } // 框架层自动注入幂等键 context.idempotencyKey generateKey(taskId, stepId, attemptNumber);这个设计看起来简单但它避免了一整类难以排查的bug。我见过太多项目在重试逻辑上线后才发现某些工具被重复执行了。6.3 日志的粒度太细和太粗都是问题日志是排查问题的生命线但日志的粒度很难把握。太细了日志文件爆炸关键信息被淹没太粗了出问题时什么都查不到。这个项目的做法是结构化日志 分级采样。所有日志都是结构化的JSON包含时间戳、级别、模块、任务ID、事件类型和具体数据。对于高频事件比如每次token生成采用采样记录只保留一定比例的日志。对于关键事件比如任务开始、任务结束、错误发生全量记录。{ ts: 1700000000000, level: info, module: agent.executor, taskId: task_abc123, event: step.completed, data: { stepName: search_files, durationMs: 1234, resultSize: 5678 } }这种结构化日志的好处是你可以用任何日志分析工具来查询和聚合。比如找出所有耗时超过5秒的步骤或者统计今天的错误率都是一行查询的事。6.4 版本升级的兼容性别让用户为你的重构买单这个项目在版本升级的兼容性上做得非常克制。它遵循一个原则配置文件的格式只增不改。新版本可以增加新的配置项但不能删除或重命名已有的配置项。如果某个配置项确实需要废弃会先标记为deprecated保留至少两个大版本并且在启动时打印警告。这个策略看起来保守但它保护了用户的升级体验。用户升级版本时不需要重新学习配置格式不需要手动迁移配置文件只需要享受新功能就行。对于CLI命令也是同样的策略。已有的命令和参数不会改变行为新功能通过新命令或新参数提供。如果某个行为确实需要改变会通过一个显式的flag来切换让用户自己选择何时迁移。7. 如果我要复刻这个项目会怎么做7.1 从最小可运行闭环开始而不是从架构图开始如果让我从零开始搭一个类似的Agent项目我不会先画架构图也不会先设计所有接口。我会先做一个最小可运行闭环一个CLI命令接收用户输入调用一次LLM把结果打印出来。就这么多。这个闭环跑通之后再逐步添加工具调用、多步规划、错误处理、成本追踪、上下文管理。每添加一个功能都确保整个系统仍然是可运行的。这种增量式的开发方式比先搭架子再填肉要稳健得多因为你能在每一步都验证系统的完整性。7.2 把可观测性当成功能来设计而不是事后补丁可观测性日志、指标、追踪在很多项目里是事后补上去的结果就是补得七零八落关键路径上没有埋点出问题时抓瞎。这个项目的做法是把可观测性当成核心功能来设计每个模块在实现业务逻辑的同时就考虑好要暴露哪些指标、记录哪些日志。具体来说每个核心操作都会发出一个结构化事件这些事件既用于CLI的实时展示也用于日志记录还用于成本统计。一份数据多种用途避免了重复埋点和数据不一致的问题。7.3 为失败设计而不是为成功设计大多数项目的代码路径是为成功场景设计的失败场景是事后补的异常处理。这个项目反过来先设计失败场景的处理再设计成功路径。比如工具调用先定义如果工具执行失败怎么办、如果工具超时怎么办、如果工具返回了无效结果怎么办把这些失败路径都处理好之后成功路径反而变得很简单。这种失败优先的设计思路让系统的健壮性有了本质提升。我在实际项目里也验证过这个思路。当你把80%的精力花在失败场景上时成功场景的代码会变得异常简洁因为所有边界情况都已经被处理掉了。7.4 文档写在代码旁边而不是写在Wiki里这个项目的文档策略也值得学习。它没有独立的Wiki所有的文档都以注释的形式写在代码旁边。每个核心模块的入口文件顶部都有一段说明这个模块职责和设计决策的注释。每个复杂的函数上方都有说明输入输出和边界条件的注释。这种文档即代码的策略最大的好处是文档不会过期。当你修改代码时你就在文档旁边顺手就更新了。而写在Wiki里的文档往往在代码修改后就被遗忘了几个月后变成误导人的过时信息。8. 这些经验能迁移到哪些场景8.1 任何调用外部服务的系统把LLM替换成任何外部服务——支付网关、地图API、消息推送——这套工程实践都成立。核心思想是一样的外部依赖是不可靠的你必须为它的失败设计必须能追踪它的成本必须能观测它的行为。8.2 任何需要长时间运行的任务系统Agent执行任务的过程本质上是一个长时间运行的任务系统。中断处理、状态管理、进度反馈、错误恢复这些经验可以直接迁移到数据处理管道、批处理任务、工作流引擎等场景。8.3 任何CLI工具的开发CLI的交互设计、错误信息设计、流式输出、可组合性这些经验适用于任何CLI工具的开发。不管你做的是构建工具、部署工具还是代码生成工具用户对CLI的期待是一致的快速、可靠、可组合、错误信息清晰。8.4 任何TypeScript项目的类型设计可辨识联合、泛型约束、配置类型安全这些TypeScript技巧在任何项目里都能用。关键是要把类型系统当成设计工具而不是负担。好的类型设计能在编译期抓住大量bug让运行时错误少一个数量级。9. 我个人的几条实操建议如果你正在做或者准备做一个Agent相关的项目这几条建议可能对你有用。第一条先写错误处理再写正常逻辑。这听起来反直觉但实践下来效果很好。当你先把各种失败场景处理好正常逻辑会变得非常清晰因为你知道所有边界都被覆盖了。第二条给每个LLM调用都加上成本标签。不需要很复杂就是在调用的时候传一个标签记录这个调用属于哪个功能。一个月后你回头看成本数据会发现很多意想不到的消耗点。第三条不要过早抽象。我见过太多项目在只有两个工具的时候就设计了一套通用工具框架结果第三个工具的需求和框架不匹配框架被迫大改。先让代码重复几次等模式真正清晰了再抽象。第四条把中断当成正常流程而不是异常。用户取消、超时、系统关闭这些都是正常会发生的事情。为它们设计专门的清理逻辑而不是指望try-catch能搞定一切。第五条日志要能回答发生了什么和为什么。只记录出错了没有意义要记录出错时的上下文输入是什么、状态是什么、之前发生了什么。这些信息在排查问题时价值连城。最后分享一个我自己的习惯每次完成一个功能模块后我会假装自己是一个刚接手这个代码库的新人尝试只看代码和注释来理解这个模块。如果我发现有地方看不懂或者需要跳转到其他文件才能理解那就说明这个模块的封装还不够好或者注释还不够清楚。这个习惯帮我发现了很多自己觉得清楚但别人看不懂的设计问题。
返回列表