ARTICLE DETAIL

资讯详情

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

从零构建终端AI编程助手:agent loop与TUI设计实战

从零构建终端AI编程助手:agent loop与TUI设计实战 1. 从“pi”这个标题说起一个极简命名背后的技术野心第一次看到“pi”这个项目标题很多人会愣一下——是那个圆周率还是树莓派或者是某个数学库但如果你最近在关注 AI 编程工具这个圈子就会知道“pi”大概率指向的是一个coding agent CLI一个跑在终端里的智能编程助手。它的命名风格非常典型短、好记、好敲两三个字母就能在命令行里呼出来这本身就是一种产品哲学——工具应该像ls、cd一样自然地融入你的工作流而不是让你每次都要打一长串命令。我拿到这个标题的时候脑子里第一反应是这又是一个 LLM API 驱动的 agent loop 项目。为什么这么说因为最近一年围绕LLM API构建agent loop的工具呈爆发式增长大家都在探索同一个问题——怎么让大模型不只是聊天而是真正能读写文件、执行命令、完成一个完整的编程任务。而TUITerminal User Interface终端用户界面则是这类工具最自然的载体毕竟程序员的大部分时间都在终端里度过切来切去反而打断心流。所以这篇博文我想从一个实际使用者和构建者的角度把“pi”这类 coding agent CLI 拆开来讲清楚。它到底是什么、解决什么问题、核心的 agent loop 是怎么转起来的、TUI 层怎么设计才不别扭、LLM API 怎么接才稳定、以及我在实操中踩过的那些坑。适合谁看如果你是一个想自己搭一个编程 agent 的开发者或者你正在用这类工具但总觉得哪里不对劲又或者你只是好奇“终端里的 AI 助手”到底是怎么工作的那这篇内容应该能给你一些实在的参考。我会尽量少讲空话多讲我实际验证过的思路和参数。有些地方因为原始信息有限我会基于这类工具的常见实践做合理补全并明确告诉你哪些是通用做法、哪些是我的个人选择。2. 核心架构拆解agent loop 到底在循环什么2.1 为什么是“循环”而不是“一问一答”普通聊天机器人的交互模型很简单你发一句话它回一句话结束。但编程任务不是这样的。你让 agent “帮我把这个函数的 bug 修了”它需要先读文件、理解上下文、定位问题、改代码、跑测试、看结果、如果失败还要再改——这一连串动作不是一次 API 调用能完成的必须有一个循环机制让模型能够根据每一步的执行结果决定下一步做什么。这个循环就是agent loop的核心。用最朴素的话说把模型的输出解析成“动作”执行这个动作把执行结果喂回给模型再让它决定下一步直到任务完成或者达到终止条件。听起来简单但魔鬼全在细节里。我见过很多初版实现循环逻辑写得非常粗糙结果就是模型要么陷入死循环反复读同一个文件要么执行了一个危险命令把工作区搞乱。所以 agent loop 的设计质量直接决定了这个工具能不能用。2.2 一个可落地的 agent loop 状态机我把这类工具的循环抽象成几个状态你可以对照自己的实现看看有没有漏掉状态职责常见问题接收输入拿到用户指令和当前上下文上下文过长导致截断模型推理调用 LLM API 获取下一步动作API 超时、返回格式不符合预期动作解析从模型输出中提取工具调用解析失败、参数缺失动作执行实际执行读文件/写文件/跑命令权限、路径、超时结果回灌把执行结果格式化后塞回上下文结果太大撑爆 token终止判断判断任务是否完成或该停下判断条件太松导致空转这个表看起来平平无奇但每一行背后都有大量工程决策。比如“结果回灌”这一步如果你把npm install的完整输出几千行全塞回去下一次 API 调用可能直接超 token 限制。我的做法是对命令输出做截断只保留头尾各若干行中间用省略标记同时把退出码单独拎出来强调。这样模型既能看到关键信息又不会浪费上下文预算。再比如“终止判断”很多实现只依赖模型自己说“我完成了”但模型有时候会过度自信。我会额外加一层硬性判断如果连续 N 轮没有产生任何文件变更或命令执行就强制退出并提示用户。这个 N 我一般设成 3实测下来能挡掉大部分空转情况。2.3 工具集的设计少即是多agent loop 里模型能调用的“工具”是整个系统的能力边界。常见的有读文件、写文件、列目录、执行 shell 命令、搜索代码。有些项目还会加网页搜索、数据库查询等。我的经验是工具数量要克制。每多一个工具模型的决策空间就大一圈选错的概率也上升。而且工具的描述description会占用系统提示词的 token工具太多会让提示词臃肿。我一般只保留最核心的五六个把每个工具的参数设计得清晰明确。这里有个细节值得说写文件这个工具一定要区分“创建新文件”和“覆盖已有文件”。我早期版本没做区分结果模型在修改一个文件时直接整个覆盖把用户原有的代码全冲掉了。后来我改成覆盖已有文件时必须先读取该文件内容并且写入操作要带上“这是修改而非重写”的语义标记。这个改动之后误覆盖的情况基本消失了。3. TUI 层设计终端界面怎么做得不反人类3.1 为什么选 TUI 而不是 GUI 或 Web先说选型逻辑。GUI 和 Web 界面当然更直观但对于编程 agent 来说它们有几个天然劣势启动慢、占资源、和终端工作流割裂。你在终端里跑着测试突然要切到一个浏览器窗口去跟 AI 对话这个上下文切换成本很高。而 TUI 直接跑在终端里和你的git、vim、npm在同一个空间复制粘贴路径、查看输出都无缝衔接。另外 TUI 的分发也简单一个二进制文件或者一个脚本就能跑不需要用户装 Electron 或者开浏览器。对于 CLI 工具来说这是巨大的优势。但 TUI 的难点在于终端的能力有限怎么在字符网格里做出清晰的信息层次是个手艺活。3.2 布局与信息层次的实际取舍我试过几种布局方案最后稳定下来的结构是这样的上方是对话历史区占据大部分屏幕下方是输入区固定几行最底部是状态栏显示当前模型、token 用量、当前工作目录。对话历史区里用户输入、模型回复、工具调用、工具结果要用不同的视觉标记区分开。我用的是前缀符号加颜色用户输入用模型回复用缩进工具调用用[tool]标记工具结果用暗色显示。颜色方面终端支持 256 色的话就用不支持就退化成加粗和反色保证在简陋终端里也能看。这里有个坑不要频繁全屏重绘。早期我用的是每次更新都清屏重画结果在滚动查看历史的时候屏幕会跳来跳去非常难受。后来改成增量更新只重绘变化的部分体验好很多。如果你用的是类似blessed或ink这样的 TUI 库它们一般会帮你处理增量渲染但你要注意别在循环里手动调清屏。3.3 输入区的交互细节输入区看起来简单其实细节很多。最基本的是支持多行输入——编程指令经常需要换行比如你贴一段代码让 agent 分析。我用ShiftEnter或者CtrlJ来插入换行Enter直接提交。这个键位设计要符合直觉不然用户会误提交。另一个重要的是历史记录。按上箭头能翻出之前输入过的指令这个功能看似基础但极大提升效率。我还会加一个CtrlR搜索历史类似 shell 的 reverse search。还有一点输入时不要阻塞渲染。如果你在用户打字的时候同步调用 API 做补全或者别的什么输入会卡顿。所有网络请求都要异步输入区始终保持响应。提示TUI 里处理中文输入是个老大难问题不同终端对宽字符的支持参差不齐。如果你的用户群有中文输入需求务必在多个终端比如系统自带终端、iTerm2、Windows Terminal里实测光标定位和删除行为否则会出现字符错位。4. LLM API 接入稳定性和成本的双重博弈4.1 接口选型与抽象层设计coding agent 对 LLM API 的依赖是刚性的所以接口的稳定性直接决定工具能不能用。我的做法是在业务逻辑和具体 API 之间加一层抽象定义一套内部的“消息格式”和“工具调用格式”然后针对不同的 API 提供商写适配器。这样换模型或者换提供商的时候只需要改适配器agent loop 本身不动。为什么这很重要因为不同提供商的 API 在细节上差异很大有的用function_call字段有的用tool_calls数组有的返回流式增量有的只返回完整结果有的对系统提示词的处理方式还不一样。如果不做抽象你的代码里会到处是if provider xxx的分支维护起来是灾难。4.2 流式输出与超时处理编程 agent 的响应往往很长因为模型要输出思考过程、工具调用参数等。如果等完整响应再显示用户会盯着空屏幕等很久体验很差。所以流式输出几乎是必须的。但流式输出带来一个新问题工具调用的参数是分块到达的你得在流式过程中拼接这些块等完整了才能解析。我的处理方式是维护一个缓冲区按事件类型分别累积文本内容和工具调用参数当收到“结束”事件时再做最终解析。这个过程要小心处理 JSON 拼接因为分块可能切在任意位置包括字符串中间。超时方面我设了两层连接超时和读取超时。连接超时短一些比如 10 秒读取超时长一些因为模型生成可能确实慢我一般设 120 秒。如果超时了不要直接报错退出而是给用户一个重试选项并且把已经收到的部分内容保留下来。4.3 成本控制token 预算的精细管理这是很多个人开发者容易忽略的地方。agent loop 每一轮都要把完整上下文发给模型上下文随着对话进行不断增长token 消耗是加速上升的。如果不加控制一个复杂任务跑下来可能烧掉大量额度。我的做法是分层管理上下文系统提示词固定不变尽量精简把工具描述压缩到最必要的程度。近期对话完整保留最近若干轮保证模型有足够的即时上下文。早期对话做摘要压缩把之前的工具调用结果浓缩成简短描述。文件内容按需读取不要一次性把所有文件塞进去。具体参数上我会设一个总 token 预算比如 100k当上下文接近这个值时触发压缩。压缩策略我试过几种最后觉得“保留最近 5 轮完整 更早的做要点摘要”比较平衡。摘要本身也要调 API所以别太频繁我一般是在上下文用到 70% 的时候才触发一次。注意不同模型对上下文窗口的计费方式不同有的按输入输出分别计价有的有缓存机制。接入前一定要把计费规则搞清楚不然月底账单会让你怀疑人生。5. 实操搭建从零跑通一个最小可用版本5.1 环境准备与依赖选择假设你要自己搭一个类似“pi”的工具我建议的技术栈是这样的运行时用 Node.js 或者 Python 都行看你熟悉哪个。Node.js 在 TUI 生态上更成熟一些有ink、blessed这些库Python 的话有textual、rich也很强。我两个都用过个人更偏向 Node.js因为 npm 生态里处理流式和异步的库更顺手。依赖方面核心就几块TUI 渲染库、HTTP 客户端调 LLM API、文件系统操作Node 内置的fs就够、命令执行child_process。不需要引入太重的框架这类工具越轻越好。5.2 最小 agent loop 的实现骨架下面是我常用的一个骨架用伪代码表示你可以照着填async function agentLoop(userInput, context) { context.push({ role: user, content: userInput }); while (true) { // 1. 检查 token 预算必要时压缩 if (estimateTokens(context) BUDGET * 0.7) { context await compressContext(context); } // 2. 调用 LLM API const response await callLLM(context, tools); // 3. 解析响应 const { text, toolCalls } parseResponse(response); // 4. 如果没有工具调用说明模型在回复用户结束循环 if (!toolCalls || toolCalls.length 0) { renderToUser(text); context.push({ role: assistant, content: text }); break; } // 5. 执行工具调用 for (const call of toolCalls) { const result await executeTool(call); context.push({ role: tool, content: result }); } // 6. 空转检测 if (noProgressFor(3)) { renderToUser(看起来任务没有进展我先停下了。); break; } } }这个骨架里compressContext、executeTool、noProgressFor是需要你根据实际情况实现的。executeTool尤其要注意安全后面会专门讲。5.3 工具执行的安全边界让 AI 执行 shell 命令这件事本身就带着风险。我的原则是默认只读写操作要确认。读文件、列目录、搜索这些操作可以直接执行写文件、删除文件、执行可能修改系统的命令要么限制在工作目录内要么弹出来让用户确认。具体实现上我会维护一个命令白名单比如ls、cat、grep、git status这些只读命令直接放行rm、mv、chmod这些必须确认sudo之类的直接拒绝。路径方面所有文件操作都要做规范化确保不会跳出工作目录防止模型被诱导去读写系统文件。还有一个细节命令执行要设超时。有些命令会挂起等待输入如果不设超时agent 就卡死了。我一般设 30 秒超时超时后杀掉进程并把超时信息返回给模型。6. 常见问题与排查技巧实录6.1 启动阶段的典型报错热词里有个报错信息很典型“error: account/read failed during tui bootstrap”。这类错误一般发生在 TUI 初始化阶段工具在启动时尝试读取账户信息或者配置但读取失败了。排查思路是这样的现象可能原因排查方法启动即报 account/read failed配置文件缺失或格式错误检查配置目录下的文件是否存在、JSON 是否合法启动卡在 bootstrap网络请求阻塞了初始化看是否有同步的网络调用改成异步或加超时TUI 渲染错乱终端类型不识别检查 TERM 环境变量尝试设置成 xterm-256color中文显示异常宽字符处理问题换终端测试或引入 wcwidth 类库计算显示宽度这个报错的核心启示是初始化阶段不要做可能失败的重操作。账户读取、配置加载这些要么做好失败兜底要么放到后台异步做别阻塞 TUI 的启动。用户看到界面出来了心里就踏实一半。6.2 agent 行为异常的排查agent 跑着跑着行为不对劲是最让人头疼的。我总结了几类高频问题第一类是工具调用参数错误。模型有时候会编造不存在的参数或者把参数类型搞错。解决办法是在工具定义里把参数 schema 写清楚并且在执行前做校验校验失败就把错误信息返回给模型让它重试。第二类是上下文丢失。模型突然“忘记”了之前读过的文件内容这通常是上下文压缩导致的。排查方法是把发给 API 的完整上下文 dump 出来看确认关键信息还在不在。如果压缩策略太激进就调松一点。第三类是死循环。模型反复执行同一个动作比如一直读同一个文件。这时候空转检测就派上用场了。我还会在系统提示词里明确写“如果连续两次得到相同结果请换一种思路”这个提示能减少一部分死循环。6.3 性能与体验优化清单最后整理一份我常用的优化清单都是实测有效的首字延迟流式输出一定要开用户看到第一个字出来感知等待时间就短很多。工具结果截断命令输出超过 200 行就截断保留头 100 行尾 100 行。并行工具调用如果模型一次返回多个独立的工具调用可以并行执行省时间。缓存文件读取同一个文件在一次会话里读多次可以缓存避免重复 IO。优雅退出CtrlC要能干净地中断当前操作并退出不要留下僵尸进程。提示调试 agent 的时候强烈建议加一个“详细模式”把每次 API 请求和响应、每次工具调用和结果都打到日志文件里。出问题的时候翻日志比盯着屏幕猜快十倍。7. 关于扩展方向的一些个人想法这个工具跑通之后能扩展的地方其实很多。比如subagent机制——让主 agent 把子任务派发给专门的子 agent各自有独立的上下文最后汇总结果。这在处理大型任务时很有用能避免单一上下文被撑爆。再比如skill 导入把常用的操作流程封装成可复用的技能包agent 需要的时候直接调用不用每次重新推理。我自己还在实验的一个方向是把 agent 的执行过程做成可回放的。每一步的工具调用和结果都记录下来任务完成后可以像看录像一样回看整个推理链条。这对调试和教学都很有价值也能让用户更信任 agent 的决策。不过话说回来工具再花哨核心还是那个朴素的 agent loop 要转得稳。我见过太多项目在花哨功能上堆料结果基础的循环逻辑一堆 bug用起来处处别扭。把循环做扎实、把工具做可靠、把错误处理做完善这三件事做到位一个 coding agent CLI 就已经能打八十分了。剩下的二十分慢慢迭代就好。
返回列表