ARTICLE DETAIL

资讯详情

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

从零构建 coding agent CLI:TUI、Agent Loop 与 LLM 函数调用实战

从零构建 coding agent CLI:TUI、Agent Loop 与 LLM 函数调用实战 1. 从“pi”这个标题说起一个极简命名背后的技术野心第一次看到“pi”这个项目标题很多人会本能地联想到数学常数或者树莓派Raspberry Pi再或者某个缩写。但如果你最近在开发者社区里泡过尤其是关注 LLM 应用和命令行工具的那批人大概率已经意识到这个“pi”指的是一类新兴的coding agent CLI工具它把大模型能力直接塞进了终端用一套极简的交互范式让写代码、调 API、跑 agent loop 这件事变得像敲ls一样自然。我最早接触这类工具是在去年底当时市面上已经有不少基于 LLM 的代码助手但绝大多数要么是 IDE 插件要么是网页聊天框。前者太重后者太散。而“pi”这类项目的切入点非常刁钻它不跟你抢编辑器也不逼你开浏览器而是直接占据你本来就一直在用的终端。你不需要切换窗口不需要复制粘贴上下文甚至不需要离开当前目录。输入pi回车一个 TUITerminal User Interface界面弹出来你就能用自然语言描述需求它帮你生成代码、执行命令、读取文件、调用 API甚至自己循环迭代直到任务完成。这背后涉及几个核心技术点LLM API 的流式调用与函数调用function calling、agent loop 的状态管理与终止条件、TUI 的渲染与输入处理、以及coding agent CLI 的上下文注入策略。这些词在热搜里反复出现说明大家真正关心的不是“又一个 AI 工具”而是“这东西到底怎么跑起来的、我能不能自己改、遇到报错怎么办”。这篇文章适合三类人第一类是想理解 coding agent 底层机制的开发者第二类是想自己搭一个类似 CLI 工具的技术爱好者第三类是在使用过程中遇到error: account/read failed during tui bootstrap这类报错、想快速排查的实操派。我会从整体设计思路讲到具体实现细节再结合常见问题给出排查路径。不堆砌术语不搞玄学尽量把每个“为什么”说清楚。2. 整体设计与思路拆解为什么是 CLI TUI Agent Loop2.1 为什么选择终端作为交互入口终端是开发者的“母语环境”。你已经在里面跑 git、npm、docker、ssh再多一个pi并不会增加认知负担。相比之下IDE 插件需要适配不同编辑器VS Code、JetBrains、Neovim 各有各的 API网页版又需要处理登录、跨域、上下文同步。CLI 的好处是零集成成本只要你的机器能跑 Node.js 或 Python就能跑起来。而且终端天然支持管道、重定向、环境变量这意味着 agent 可以直接调用你本地的工具链而不是在一个沙箱里模拟。另一个关键考量是上下文获取的便利性。在终端里当前工作目录、git 状态、环境变量、最近执行的命令这些都是现成的。一个设计良好的 coding agent CLI 会在启动时自动收集这些信息注入到 system prompt 里。你不需要手动告诉它“我在哪个项目、用的什么框架”它自己就能从package.json、pyproject.toml、Cargo.toml里推断出来。这种“环境感知”能力是网页版助手很难做到的。2.2 TUI 不是装饰是效率工具很多人觉得 TUI 只是“看起来酷”其实不然。一个成熟的 TUI 在 agent 场景下解决了三个实际问题流式输出的实时渲染、多轮对话的历史滚动、工具调用状态的可视化。当你让 agent 执行一个耗时命令时TUI 可以显示 spinner、进度条、或者实时日志当 agent 调用多个工具时TUI 可以把每次调用的输入输出折叠起来让你按需展开。这些在纯文本 REPL 里做起来很别扭在网页里又太重。热搜里出现的error: account/read failed during tui bootstrap恰恰说明 TUI 启动阶段涉及账号读取、配置加载、工作区初始化等一系列步骤。任何一个环节失败都会导致 bootstrap 中断。理解这个流程对排查问题至关重要。2.3 Agent Loop 的核心不是一次问答而是循环逼近普通聊天机器人是“一问一答”而 agent loop 是“一问、一做、一看、再问”。具体来说一个典型的循环包含以下阶段感知Perceive收集当前状态包括用户输入、上一步工具执行结果、文件变更等。规划PlanLLM 根据 system prompt 和当前状态决定下一步做什么——是直接回答还是调用某个工具。行动Act如果决定调用工具就解析函数调用参数执行对应操作读文件、写文件、跑命令、调 API。观察Observe把工具执行结果返回给 LLM。判断DecideLLM 判断任务是否完成。如果完成输出最终答案如果未完成回到第 2 步。这个循环的终止条件设计非常关键。如果设计得太宽松agent 可能陷入死循环反复执行同一个命令如果太严格又可能提前终止任务没做完就停了。常见的做法是设置最大迭代次数比如 20 轮和重复动作检测如果连续两次调用相同工具且参数相同就强制终止并提示用户。2.4 工具选型为什么是这些而不是那些在实现层面这类项目通常面临几个选择维度选项 A选项 B常见选择及理由运行时Node.jsPythonNode.js 更适合 TUIInk、Blessed 生态成熟Python 更适合快速原型LLM 接入官方 SDK统一 API 层统一 API 层更灵活方便切换模型TUI 框架InkReactBlessed / TextualInk 组件化好适合复杂布局Textual 功能强但学习曲线陡配置存储本地 JSON环境变量本地 JSON 便于管理多账号环境变量适合 CI工具执行直接 shell沙箱直接 shell 效率高但需要用户信任沙箱安全但限制多我个人的经验是如果你只是想自己用直接 shell 执行最省事如果要给别人用至少加一个“确认后执行”的步骤。热搜里的pi subagent暗示这类工具可能支持子 agent 机制也就是主 agent 可以派生子 agent 去处理特定子任务每个子 agent 有自己的上下文和工具集。这种设计在复杂任务里很有用但也会增加状态管理的复杂度。3. 核心细节解析与实操要点从 API 调用到 TUI 渲染3.1 LLM API 的流式调用与函数调用流式调用是 TUI 体验的基础。如果等 LLM 生成完整回复再显示用户会盯着空屏幕好几秒。流式调用让 token 一个个蹦出来心理上感觉快很多。实现上大多数 API 都支持stream: true参数返回一个 SSEServer-Sent Events流。你需要逐块解析提取delta.content并追加到当前消息。函数调用function calling / tool use是 agent 的核心。你需要在请求里定义一组工具每个工具包含名称、描述、参数 schema。LLM 在需要时会返回一个tool_calls数组里面包含工具名和参数 JSON。你的代码解析这个 JSON执行对应函数然后把结果作为一条role: tool的消息追加到对话历史里再次请求 LLM。这里有个容易踩的坑参数 JSON 的解析容错。LLM 生成的 JSON 偶尔会多一个逗号、少一个引号或者把数字写成字符串。直接JSON.parse会抛异常。稳妥的做法是用一个宽松的解析器或者在解析失败时把原始字符串返回给 LLM让它自己修正。// 一个简单的流式解析示例Node.js const response await fetch(apiUrl, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify({ model, messages, tools, stream: true }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); // 保留最后一行不完整的 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) continue; const parsed JSON.parse(data); const delta parsed.choices[0]?.delta; if (delta?.content) process.stdout.write(delta.content); if (delta?.tool_calls) handleToolCalls(delta.tool_calls); } } }3.2 Agent Loop 的状态管理状态管理是 agent loop 最容易出问题的地方。你需要跟踪的东西包括当前对话历史、已执行的工具调用记录、当前迭代次数、是否处于等待用户确认状态、是否有未完成的工具调用。一个常见的错误是对话历史无限增长导致每次请求 token 数越来越多最终超出模型上下文窗口。解决办法有两种一是滑动窗口只保留最近 N 轮对话二是摘要压缩把早期对话用 LLM 总结成一段简短描述。我实测下来滑动窗口更简单可靠但会丢失早期上下文摘要压缩保留信息多但增加一次额外 API 调用。折中方案是保留 system prompt 最近 10 轮完整对话 更早对话的摘要。另一个坑是工具调用结果的格式。有些 API 要求工具结果必须是字符串有些允许结构化数据。如果你直接把一个对象塞进去可能会报错。稳妥做法是统一JSON.stringify后再传入。3.3 TUI 渲染的关键细节TUI 渲染的核心挑战是在流式输出和界面刷新之间取得平衡。如果每次收到一个 token 就重绘整个界面性能会很差如果攒一批再重绘又会有延迟感。常见的做法是使用一个双缓冲机制后台维护一个虚拟屏幕流式输出时只更新变化的部分然后以固定帧率比如 30fps刷新到真实终端。热搜里的error: account/read failed during tui bootstrap通常发生在 TUI 初始化阶段。这个阶段一般包括读取配置文件、验证 API key、检查工作区状态、加载历史记录。如果配置文件路径不对、API key 过期、或者工作区权限不足都会导致 bootstrap 失败。排查时可以先看日志文件通常会在~/.pi/logs或类似目录下。3.4 上下文注入策略一个 coding agent 好不好用很大程度上取决于它能不能“看懂”你的项目。上下文注入通常分三层静态上下文项目结构、依赖清单、README 摘要。这些在启动时扫描一次即可。动态上下文当前 git 分支、最近 commit、未提交变更。这些在每次用户输入前刷新。按需上下文当 agent 决定读取某个文件时才去读那个文件的内容。静态上下文不要塞太多否则浪费 token。我一般只注入package.json的dependencies和scripts字段加上目录树的前两层。动态上下文用git status --short和git log --oneline -5就够了。按需上下文才是重头戏agent 应该有能力自己决定读哪个文件。注意不要一次性把整个代码库塞进 prompt。即使模型支持长上下文成本和延迟也会让你后悔。让 agent 自己按需读取才是可持续的做法。4. 实操过程与核心环节实现从零搭一个最小可用版本4.1 环境准备与依赖安装假设我们用 Node.js 来实现一个最小版本。你需要Node.js 18因为要用原生 fetch一个 LLM API key支持函数调用的模型可选的 TUI 库ink或blessed初始化项目mkdir pi-mini cd pi-mini npm init -y npm install ink react chalk如果你不想用 React 那套blessed更轻量但组件化差一些。我选ink是因为它的布局系统更直观适合快速搭出可用的界面。4.2 配置文件设计配置文件放在~/.pi-mini/config.json结构如下{ apiKey: your-key-here, baseUrl: https://api.example.com/v1, model: gpt-4-turbo, maxIterations: 20, workspace: /current/project/path, tools: { readFile: true, writeFile: true, runCommand: true } }读取配置时要做容错如果文件不存在引导用户创建如果字段缺失用默认值填充。热搜里的account/read failed很可能就是配置文件读取失败导致的。建议在读取失败时给出明确的错误信息而不是直接崩溃。4.3 工具定义与实现我们定义三个核心工具const tools [ { type: function, function: { name: read_file, description: 读取指定路径的文件内容, parameters: { type: object, properties: { path: { type: string, description: 文件路径相对于工作区根目录 } }, required: [path] } } }, { type: function, function: { name: write_file, description: 写入内容到指定文件如果文件不存在则创建, parameters: { type: object, properties: { path: { type: string }, content: { type: string } }, required: [path, content] } } }, { type: function, function: { name: run_command, description: 在工作区执行 shell 命令, parameters: { type: object, properties: { command: { type: string } }, required: [command] } } } ];实现时read_file和write_file要限制在工作区目录内防止路径穿越。run_command要设置超时比如 30 秒避免卡死。const path require(path); const fs require(fs).promises; const { exec } require(child_process); const WORKSPACE config.workspace; async function executeTool(name, args) { switch (name) { case read_file: { const fullPath path.resolve(WORKSPACE, args.path); if (!fullPath.startsWith(WORKSPACE)) throw new Error(路径越界); return await fs.readFile(fullPath, utf-8); } case write_file: { const fullPath path.resolve(WORKSPACE, args.path); if (!fullPath.startsWith(WORKSPACE)) throw new Error(路径越界); await fs.writeFile(fullPath, args.content, utf-8); return 写入成功; } case run_command: { return new Promise((resolve) { exec(args.command, { cwd: WORKSPACE, timeout: 30000 }, (err, stdout, stderr) { if (err) resolve(错误: ${err.message}\n${stderr}); else resolve(stdout || stderr || 命令执行完成无输出); }); }); } default: throw new Error(未知工具: ${name}); } }4.4 Agent Loop 主循环实现主循环的逻辑是把用户输入追加到消息历史然后反复调用 LLM直到没有工具调用或达到最大迭代次数。async function agentLoop(userInput, messages) { messages.push({ role: user, content: userInput }); let iterations 0; while (iterations config.maxIterations) { iterations; const response await callLLM(messages, tools); const choice response.choices[0]; const message choice.message; messages.push(message); if (!message.tool_calls || message.tool_calls.length 0) { return message.content; // 没有工具调用任务结束 } for (const toolCall of message.tool_calls) { const { name, arguments: argsStr } toolCall.function; let args; try { args JSON.parse(argsStr); } catch (e) { messages.push({ role: tool, tool_call_id: toolCall.id, content: 参数解析失败: ${e.message}原始参数: ${argsStr} }); continue; } let result; try { result await executeTool(name, args); } catch (e) { result 工具执行失败: ${e.message}; } messages.push({ role: tool, tool_call_id: toolCall.id, content: typeof result string ? result : JSON.stringify(result) }); } } return 达到最大迭代次数任务可能未完成。; }4.5 TUI 界面搭建用ink搭一个简单界面const React require(react); const { render, Box, Text, useInput } require(ink); function App() { const [messages, setMessages] React.useState([]); const [input, setInput] React.useState(); const [loading, setLoading] React.useState(false); useInput((char, key) { if (key.return) { if (input.trim() !loading) { setLoading(true); agentLoop(input, messages).then((reply) { setMessages([...messages, { role: assistant, content: reply }]); setLoading(false); }); setInput(); } } else if (key.backspace || key.delete) { setInput(input.slice(0, -1)); } else { setInput(input char); } }); return React.createElement(Box, { flexDirection: column }, messages.map((m, i) React.createElement(Text, { key: i }, ${m.role}: ${m.content})), loading ? React.createElement(Text, { color: yellow }, 思考中...) : null, React.createElement(Text, { color: green }, ${input}) ); } render(React.createElement(App));这个版本很粗糙但核心逻辑都在。你可以在此基础上加流式渲染、工具调用折叠、历史滚动等功能。4.6 参数计算与选择过程在配置maxIterations时我一般设为 20。这个数字怎么来的假设一个典型任务需要 3-5 次工具调用每次调用前后各一次 LLM 请求那么 20 次迭代大约能覆盖 6-8 个工具调用。对于大多数日常任务够用了。如果设得太高遇到死循环时会浪费大量 token设得太低复杂任务做不完。run_command的超时设为 30 秒是因为大多数开发命令npm install、pytest、cargo build在 30 秒内要么完成要么明显卡住了。如果确实需要长时间运行可以让 agent 用放到后台或者提示用户手动执行。5. 常见问题与排查技巧实录5.1 TUI 启动失败account/read failed这是热搜里出现频率最高的报错。根据我的经验原因通常有这几类报错信息可能原因排查方法account/read failed during tui bootstrap配置文件不存在或格式错误检查~/.pi/config.json是否存在JSON 是否合法account/read failed: worksp...工作区路径不存在或无权限确认配置里的 workspace 路径存在且可读写account/read failed: invalid keyAPI key 过期或格式不对重新生成 key确认没有多余空格account/read failed: network网络不通或 baseUrl 错误用 curl 测试 baseUrl 是否可达排查顺序建议先看日志文件再检查配置文件最后测试网络。日志通常在~/.pi/logs/下按日期命名。如果日志里没有有用信息可以在启动时加--verbose参数如果支持的话。5.2 Agent 陷入死循环症状是 agent 反复执行同一个命令或者反复读取同一个文件。原因通常是 LLM 没有正确理解工具返回的结果或者任务本身无法完成但 LLM 不肯放弃。解决办法设置最大迭代次数前面已经提到。加一个重复动作检测如果连续两次工具调用名称和参数完全相同就中断循环返回提示。在 system prompt 里明确告诉 LLM“如果连续两次得到相同结果请停止并告知用户。”let lastToolCall null; let repeatCount 0; // 在循环内 const currentToolCall JSON.stringify({ name, args }); if (currentToolCall lastToolCall) { repeatCount; if (repeatCount 2) { return 检测到重复操作已终止。请检查任务描述或手动介入。; } } else { repeatCount 0; } lastToolCall currentToolCall;5.3 工具调用参数解析失败LLM 生成的 JSON 参数偶尔会出问题。除了前面提到的宽松解析还可以在 system prompt 里强调“工具参数必须是合法 JSON不要包含注释、尾随逗号或未转义字符。”如果还是失败就把解析错误信息返回给 LLM让它重新生成。5.4 流式输出卡顿或乱码这通常是 TUI 渲染和流式解析的配合问题。检查两点一是TextDecoder是否正确处理了多字节字符用{ stream: true }二是 TUI 刷新频率是否过高。如果每收到一个 token 就setStateReact 会频繁重渲染导致卡顿。解决办法是用一个缓冲区每 50ms 批量更新一次。5.5 上下文超出模型限制当对话历史太长时API 会返回context_length_exceeded错误。解决办法前面提过滑动窗口或摘要压缩。我一般用滑动窗口保留最近 10 轮加上 system prompt。如果任务确实需要早期上下文可以让 agent 把关键信息写入一个临时文件需要时再读回来。实操心得在 system prompt 里加一句“如果需要记住重要信息请用 write_file 写入 .pi-notes.md”这样即使对话被截断关键信息也不会丢。5.6 子 agentsubagent的协调问题热搜里出现了pi subagent说明这类工具可能支持子 agent。子 agent 的典型用法是主 agent 把一个大任务拆成几个子任务每个子任务交给一个独立的子 agent 去处理子 agent 有自己的上下文和工具集。好处是隔离性好坏处是状态同步复杂。常见问题是子 agent 的结果没有正确返回给主 agent或者子 agent 之间互相等待导致死锁。解决办法是给每个子 agent 设置独立的超时并且主 agent 要能处理子 agent 失败的情况。我一般会让子 agent 把结果写入一个约定好的文件主 agent 轮询读取而不是直接传递内存对象。6. 扩展方向与个人经验分享这类 coding agent CLI 的扩展空间很大。你可以加多模型切换让 agent 根据任务类型自动选择模型可以加插件系统让用户自定义工具可以加会话持久化把对话历史存到本地数据库下次启动时恢复。热搜里的pi web导入skill暗示可能支持从网页导入技能定义这本质上就是插件系统的一种形式。我在实际使用中最大的体会是不要指望 agent 一次做对。它的价值在于快速迭代而不是一次完美。你给它一个模糊的需求它可能前几次都跑偏但你可以通过追加指令、修改文件、甚至直接编辑对话历史来引导它。把它当成一个反应很快但需要监督的实习生而不是一个全知全能的专家。另外日志一定要详细。每次 API 请求和响应、每次工具调用和结果都记下来。出问题时这些日志就是你的救命稻草。我习惯把日志按天切分保留最近 7 天既不占空间又够排查用。最后分享一个小技巧在 system prompt 里加一句“在执行危险命令如 rm -rf、git reset --hard前必须先询问用户确认”。这个简单的规则能避免很多灾难性误操作。agent 再聪明也不该拥有无限制的破坏力。
返回列表