ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建 coding agent CLI:TUI 与 Agent Loop 架构解析

从零构建 coding agent CLI:TUI 与 Agent Loop 架构解析

1. 从“pi”这个标题说起:一个极简命名背后的技术野心

第一次看到“pi”这个项目标题,很多人会愣一下——是那个圆周率?是树莓派?还是某个数学库?但如果你最近在开发者社区里泡过,尤其是关注 LLM 应用和 coding agent 这个方向,就会知道这里说的“pi”大概率指向的是一个coding agent CLI 工具,一个跑在终端里的智能编程助手。它的命名极简到近乎任性,但恰恰是这种命名方式,透露出作者对产品定位的极度自信:不需要花哨的名字,核心能力说话。

我接触过不少 coding agent 类的工具,从早期的补全插件到后来的对话式编程助手,大多数产品要么做成 IDE 插件,要么做成 Web 应用。而“pi”选择了一条更硬核的路线——TUI(Terminal User Interface)。这意味着它不依赖图形界面,直接在终端里运行,通过键盘交互完成代码理解、生成、修改、执行等一系列操作。对于长期在终端里工作的开发者来说,这种形态反而更自然:不用切换窗口,不用等 IDE 加载,打开终端就能干活。

这个项目解决的核心问题其实很明确:让 LLM 真正参与到编码流程中,而不是只做一个被动的问答机器。传统的 LLM API 调用是“你问我答”,你贴一段代码,它给一段建议,然后你自己去改。而 pi 这类工具要做的是agent loop——它能自己读文件、自己分析上下文、自己决定下一步动作、自己执行命令、自己检查结果,形成一个闭环。你只需要给出一个高层目标,比如“把这个模块的错误处理重构一下”,它就能自己规划步骤并执行。

适合谁来参考这篇文章?如果你是一个经常写代码的开发者,对 LLM API 有基本了解,想搞清楚一个 coding agent CLI 到底是怎么运转的,或者你自己想动手做一个类似的东西,那这篇内容就是写给你的。我会从架构设计、核心循环、TUI 实现、工具调用、错误处理这几个维度,把 pi 这类项目的里里外外拆开讲清楚。即使你之前没接触过 agent 开发,跟着思路走也能理解个七七八八。

2. 整体架构设计:为什么是 TUI + Agent Loop 的组合

2.1 终端界面不是倒退,而是精准匹配使用场景

很多人第一反应是:都什么年代了,还做终端工具?GUI 不香吗?我一开始也这么想,但实际用下来发现,对于 coding agent 这个场景,TUI 有几个 GUI 无法替代的优势。

首先是启动速度。一个 Electron 写的桌面应用,冷启动动辄好几秒,而一个编译好的终端程序,基本是毫秒级启动。coding agent 的使用频率很高,你可能一天要调用几十次,每次省几秒,累积下来就是可观的效率提升。其次是环境一致性。终端工具天然跨平台,SSH 到远程服务器也能用,不需要考虑图形界面的兼容性问题。你在本地终端里怎么用,在远程开发机上就怎么用,体验完全一致。

再就是键盘流的工作方式。写代码的人本来就习惯键盘操作,TUI 可以把所有功能都映射到快捷键上,手不用离开键盘就能完成所有操作。pi 的 TUI 设计里,通常会有几个核心区域:对话历史区、输入区、状态栏、以及可能的文件预览区。对话历史区展示 agent 的思考和执行过程,输入区让你输入指令,状态栏显示当前模型、token 消耗、执行状态等信息。

提示:如果你要自己实现一个类似的 TUI,不要一上来就追求花哨的界面效果。先把输入输出循环跑通,再逐步加上状态显示、快捷键、滚动历史这些功能。很多项目死在过度设计上。

2.2 Agent Loop 是整个系统的心脏

Agent loop 这个概念听起来玄乎,其实本质就是一个带工具调用的对话循环。普通的 LLM 调用是:用户输入 -> 模型输出 -> 结束。而 agent loop 是:用户输入 -> 模型决定调用某个工具 -> 系统执行工具 -> 把结果返回给模型 -> 模型继续决策 -> 直到模型认为任务完成。

这个循环的关键在于模型输出的结构化。模型不能只是输出一段自然语言,它需要输出一个结构化的动作指令,比如“读取文件 xxx”、“执行命令 yyy”、“写入文件 zzz”。通常的做法是让模型输出 JSON 格式的工具调用请求,系统解析后执行,再把执行结果作为新的消息追加到对话历史里。

pi 这类工具通常会定义一组基础工具:文件读取、文件写入、命令执行、目录列表、代码搜索等。模型在每一轮循环中可以选择调用其中一个或多个工具。系统执行完工具后,把结果反馈给模型,模型再决定下一步。这个循环会一直持续,直到模型输出一个“完成”信号,或者达到预设的最大轮次限制。

为什么需要最大轮次限制?因为模型有时候会陷入死循环,比如反复读取同一个文件却得不出结论。设置一个上限(比如 20 轮或 50 轮)可以防止 token 被无限消耗。这个上限的设定需要根据任务复杂度来调整,简单的重构任务可能 5 轮就够了,复杂的调试任务可能需要 30 轮以上。

2.3 工具调用的设计哲学:少即是多

我见过一些 agent 项目,定义了上百个工具,恨不得把整个操作系统的 API 都包一遍。但实际用下来,真正高频使用的工具就那么几个。pi 的设计思路明显是克制的,核心工具可能不超过十个。

文件读取和文件写入是最基础的两个。文件读取需要支持按行范围读取,因为大文件不可能一次性全部塞进上下文。通常的做法是默认读取前 N 行,或者根据模型指定的行号范围读取。文件写入则需要考虑是覆盖还是追加,以及是否要创建备份。

命令执行是最强大也最危险的工具。它让 agent 可以运行测试、安装依赖、执行构建脚本。但同时也带来了安全风险——如果模型生成了一个破坏性的命令,后果可能很严重。所以命令执行工具通常需要加一层确认机制,或者限制在沙箱环境中运行。

代码搜索工具也很关键。当项目很大时,模型不可能靠逐个读取文件来理解代码结构。一个基于关键词或正则的搜索工具,可以快速定位到相关代码片段。这个工具的实现可以很简单,就是遍历目录下的文件,用正则匹配内容,返回匹配的行号和上下文。

注意:工具的定义要尽可能原子化。不要做一个“重构代码”这样的大工具,而是提供“读取文件”、“写入文件”、“搜索代码”这些小工具,让模型自己组合使用。原子化的工具更灵活,也更容易调试。

3. 核心细节解析:LLM API 调用与上下文管理

3.1 模型选型与 API 调用的实际考量

pi 作为一个 coding agent,底层依赖的是 LLM API。目前主流的选择无非就是那几家:OpenAI 的 GPT 系列、Anthropic 的 Claude 系列、以及各种开源模型的自部署方案。选哪个模型,直接决定了 agent 的智商上限和运行成本。

从实际体验来看,coding agent 对模型的指令遵循能力和长上下文理解能力要求极高。模型需要准确理解工具调用的格式要求,不能随意发挥;同时需要在一个很长的对话历史中保持对任务目标的记忆。一些在小任务上表现不错的模型,放到 agent loop 里跑几轮之后就开始胡言乱语,要么忘记了自己在干什么,要么生成了格式错误的工具调用请求。

API 调用的参数配置也有讲究。temperature通常设得比较低,0.1 到 0.3 之间,因为 coding agent 需要的是稳定和可预测的输出,而不是创意。max_tokens要设得足够大,因为模型可能需要输出较长的代码片段或分析过程。top_p一般保持默认或者稍微调低一点。

还有一个容易被忽视的参数是超时时间。agent loop 里每一次 API 调用都可能耗时几秒到几十秒,如果超时设得太短,复杂任务很容易中断。我一般会把超时设在 60 秒以上,并且实现重试机制。重试的时候要注意,不能简单地重复同一个请求,因为模型每次生成的结果可能不同。更好的做法是记录失败原因,在重试时调整参数或者提示模型。

3.2 上下文窗口的管理策略

这是 coding agent 最核心也最棘手的问题之一。一个中等规模的项目,代码量轻松超过十万行,而即使是最先进的模型,上下文窗口也就几十万 token。你不可能把整个项目塞进去,必须做取舍。

常见的策略有几种。第一种是按需读取,agent 根据当前任务决定读哪些文件。比如任务是修改某个函数,那就只读包含这个函数的文件,以及相关的测试文件。这种策略依赖模型的判断能力,如果模型判断失误,可能会遗漏关键上下文。

第二种是摘要压缩,把之前的对话历史和文件内容做摘要,只保留关键信息。这种策略适合长对话场景,但摘要本身也会丢失细节,可能导致模型做出错误决策。

第三种是分层索引,先给模型一个项目结构的概览,比如目录树和每个文件的简要描述,然后模型根据需要请求读取具体文件。这种策略结合了前两种的优点,但实现复杂度较高。

pi 在实际运行中,大概率是组合使用这些策略。比如先加载项目根目录的文件列表,然后根据用户指令让模型决定读取哪些文件。对话历史超过一定长度后,自动对早期内容做摘要。文件内容读取时,只读取相关行范围,而不是整个文件。

提示:上下文管理没有银弹。你需要根据自己项目的规模和任务类型,不断调整策略。一个实用的技巧是给模型一个“记忆文件”,让它把重要的决策和发现写进去,后续轮次再读出来。这样可以在不增加太多 token 的情况下保持任务连贯性。

3.3 工具调用结果的格式化与回传

模型输出工具调用请求后,系统执行工具,然后把结果回传给模型。这个回传的格式很重要,直接影响模型下一轮的决策质量。

结果不能只是原始的输出,还需要加上一些元信息。比如文件读取的结果,应该包含文件路径、读取的行范围、总行数。命令执行的结果,应该包含退出码、标准输出、标准错误。这些元信息帮助模型判断操作是否成功,以及下一步该怎么做。

如果工具执行失败,错误信息也要清晰地回传。不要只给一个“执行失败”,而要给出具体的错误原因。比如文件不存在、权限不足、命令返回非零退出码等。模型看到具体的错误信息后,才有可能做出正确的修复决策。

结果的长度也需要控制。如果命令输出特别长,比如跑了一个测试套件输出了几千行日志,直接全部回传会迅速消耗上下文。通常的做法是截断,只保留开头和结尾的部分,或者只保留包含错误关键词的行。截断策略需要根据工具类型来定制,不能一刀切。

4. 实操过程:从零搭建一个最小可用的 Agent Loop

4.1 环境准备与依赖安装

假设我们要用 Python 来实现一个类似 pi 的最小原型。首先需要准备环境。Python 版本建议 3.10 以上,因为要用到一些新的类型语法。依赖方面,核心就是 LLM 的 SDK,比如 openai 或者 anthropic 的官方库。TUI 部分可以用 textual 或者 rich,这两个库都能快速搭建终端界面。

pip install openai textual rich

如果你打算支持多种模型,可以再装一个 litellm,它提供了统一的接口来调用不同厂商的模型。不过对于最小原型来说,先用一家的 SDK 跑通流程更重要。

项目结构建议这样组织:一个主入口文件负责启动 TUI,一个 agent 模块负责核心循环,一个 tools 模块定义所有工具,一个 llm 模块封装 API 调用。这样分层清晰,后续扩展也方便。

4.2 定义工具集与执行器

工具的定义可以用一个字典来描述,每个工具包含名称、描述、参数 schema 和执行函数。描述很重要,模型会根据描述来判断什么时候该用这个工具。描述要写得清晰具体,不要含糊。

tools = { "read_file": { "description": "读取指定文件的内容,支持指定起始行和结束行", "parameters": { "path": "文件路径", "start_line": "起始行号,可选", "end_line": "结束行号,可选" }, "function": read_file_impl }, "write_file": { "description": "将内容写入指定文件,会覆盖原有内容", "parameters": { "path": "文件路径", "content": "要写入的内容" }, "function": write_file_impl }, "run_command": { "description": "在项目目录下执行 shell 命令,返回退出码和输出", "parameters": { "command": "要执行的命令" }, "function": run_command_impl } }

执行器的逻辑就是根据模型返回的工具名称和参数,找到对应的函数并调用。调用时要做好异常捕获,任何异常都要转成结构化的错误信息返回,而不是让程序崩溃。

4.3 实现 Agent Loop 的核心逻辑

核心循环的伪代码大概是这样:

def agent_loop(user_input, max_turns=20): messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages.append({"role": "user", "content": user_input}) for turn in range(max_turns): response = call_llm(messages) messages.append(response) if response.is_final_answer(): return response.content tool_calls = response.tool_calls for call in tool_calls: result = execute_tool(call.name, call.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": format_result(result) }) return "达到最大轮次限制,任务未完成"

这个循环看起来简单,但魔鬼在细节里。比如call_llm需要处理 API 的各种错误,execute_tool需要做参数校验,format_result需要控制输出长度。每一个环节都需要仔细打磨。

系统提示词(SYSTEM_PROMPT)的编写也是一门学问。它需要告诉模型:你是一个 coding agent,你可以使用哪些工具,工具调用的格式是什么,你应该如何规划任务,遇到错误时应该怎么处理。提示词的质量直接影响 agent 的表现,需要反复迭代优化。

4.4 TUI 界面的快速搭建

用 textual 搭建一个基础 TUI 并不复杂。核心组件包括一个用于显示对话历史的 RichLog,一个用于输入指令的 Input,以及一个显示状态的 Footer。

from textual.app import App, ComposeResult from textual.widgets import Header, Footer, RichLog, Input class PiApp(App): def compose(self) -> ComposeResult: yield Header() yield RichLog(id="history") yield Input(placeholder="输入你的指令...") yield Footer() async def on_input_submitted(self, event): user_input = event.value self.query_one("#history").write(f"> {user_input}") result = await run_agent(user_input) self.query_one("#history").write(result)

这个最小界面已经能用了。后续可以逐步加上语法高亮、工具调用状态显示、token 计数、快捷键绑定等功能。TUI 的好处就是迭代快,改几行代码就能看到效果。

注意:TUI 里的异步处理要小心。agent loop 是耗时的,不能阻塞界面线程。textual 支持 async worker,把 agent 调用放在 worker 里跑,界面就不会卡死。

5. 常见问题与排查技巧实录

5.1 模型不按格式输出工具调用怎么办

这是最常见的问题。你明明在提示词里写了要输出 JSON 格式的工具调用,但模型就是不听,要么输出一段自然语言,要么 JSON 格式错误。排查思路是这样的:

首先检查提示词是否足够明确。不要只说“输出 JSON”,而要给出具体的 schema 和示例。示例比描述更有效,模型会模仿示例的格式。其次检查 API 是否支持原生的 function calling 或 tool use 功能。如果支持,优先用原生功能,而不是靠提示词约束。原生功能的格式稳定性高得多。

如果还是不行,可以在解析失败时做一次重试,把错误信息反馈给模型,让它重新生成。重试时可以把 temperature 调得更低,增加输出的确定性。连续失败三次以上,就应该考虑换模型了,有些模型在这方面确实能力不足。

5.2 命令执行的安全边界怎么定

让 agent 执行 shell 命令是风险最高的操作。我的建议是分场景处理。如果是本地开发环境,可以相对宽松,但仍然要禁止一些危险命令,比如rm -rf /、mkfs、dd这类。可以维护一个黑名单,匹配到就拒绝执行。

如果是生产环境或者共享环境,必须用沙箱。Docker 容器是最简单的沙箱方案,把 agent 的命令执行限制在容器内,即使出了问题也不会影响宿主机。容器里只挂载项目目录,网络访问也可以限制。

还有一个实用技巧是命令预览。在执行命令之前,先把命令展示给用户,让用户确认。虽然多了一步交互,但安全性大大提升。对于高频的只读命令(比如ls、cat),可以设置白名单自动执行,写操作则必须确认。

5.3 上下文爆炸的预防与处理

跑着跑着 token 就超了,这是 agent 开发的经典问题。预防措施包括:限制单次文件读取的行数,默认不超过 500 行;命令输出超过 2000 字符就截断;对话历史超过一定轮次就做摘要。

如果已经发生了上下文爆炸,API 会返回错误。这时候需要有一个降级策略:把最早的几轮对话删掉,或者做摘要替换。摘要可以用一个便宜的模型来做,把长对话压缩成几百字的要点。

还有一个技巧是分阶段执行。把一个大任务拆成多个小任务,每个小任务单独开一个 agent loop,完成后再把结果汇总。这样每个 loop 的上下文都是独立的,不会互相污染。

5.4 常见问题速查表

问题现象可能原因排查方向解决建议
模型输出格式错误提示词不明确或模型能力不足检查提示词示例、检查 API 原生支持增加示例、启用原生 tool use、降低 temperature
命令执行无响应命令阻塞或超时设置过长检查命令是否需要交互输入设置命令超时、禁用交互式命令
上下文超限文件读取过多或对话历史过长检查 token 计数限制读取行数、截断输出、摘要历史
agent 陷入死循环任务目标不明确或工具反馈不清检查系统提示词和工具返回格式设置最大轮次、优化错误信息回传
TUI 界面卡死同步阻塞调用检查是否有耗时操作在主线程改用异步 worker、分离 IO 和 UI

5.5 几个踩过的坑和独家技巧

第一个坑是文件编码问题。读取文件时如果不指定编码,遇到非 UTF-8 的文件就会报错。建议统一用 UTF-8 读取,遇到解码错误就用 errors='replace' 兜底。写入时也要注意,有些项目要求特定的换行符格式。

第二个坑是相对路径和绝对路径的混淆。agent 执行命令时的工作目录可能和你想的不一样。建议在系统提示词里明确当前工作目录,所有路径操作都基于这个目录。执行命令时也显式指定 cwd 参数。

第三个技巧是给 agent 一个 scratchpad。在项目目录下创建一个临时文件,让 agent 把中间结果和决策记录写进去。这样即使对话历史被截断,agent 也能通过读取 scratchpad 恢复上下文。这个技巧在长任务中特别有用。

第四个技巧是工具调用的幂等性设计。文件写入工具最好支持 dry-run 模式,先展示将要写入的内容,确认后再实际写入。命令执行工具可以记录执行历史,避免重复执行相同的命令。

6. 扩展方向:从最小原型到生产可用

6.1 多模型支持与动态切换

最小原型通常只支持一家模型。但实际使用中,不同任务适合不同模型。简单的文件读取和格式整理,用便宜的小模型就够了;复杂的代码重构和调试,需要上大模型。实现多模型支持的关键是抽象一层统一的接口,把不同厂商的 API 差异屏蔽掉。

动态切换的策略可以基于任务类型,也可以基于 token 预算。比如设定一个规则:前几轮用大模型做规划,后续执行用小模型。或者当 token 消耗超过阈值时,自动降级到便宜模型。这种策略可以显著降低成本。

6.2 子 agent 与任务分解

当任务复杂度上升到一定程度,单个 agent loop 可能力不从心。这时候可以引入子 agent的概念。主 agent 负责分解任务和协调,子 agent 负责执行具体的小任务。每个子 agent 有独立的上下文,完成后把结果汇报给主 agent。

这种架构的挑战在于通信和协调。主 agent 需要清晰地定义子任务的目标和边界,子 agent 需要准确地汇报结果和遇到的问题。实现上可以用消息队列或者简单的函数调用来做。pi 的热搜词里出现了“pi subagent”,说明这个方向确实是社区关注的重点。

6.3 技能系统与可扩展性

“pi web导入skill”这个热搜词暗示了 pi 可能有一个技能系统。技能可以理解为预定义的工具组合或者提示词模板。比如“重构技能”可能包含一系列固定的步骤:分析代码、识别坏味道、生成重构方案、执行修改、运行测试。用户可以通过导入技能来扩展 agent 的能力,而不需要修改核心代码。

技能系统的设计要点是声明式和可组合。技能用配置文件描述,而不是硬编码。技能之间可以互相调用,形成更复杂的技能。这样社区就可以贡献各种技能,生态才能活起来。

6.4 错误恢复与断点续跑

生产环境中,agent 运行过程中可能因为各种原因中断:网络问题、API 限流、程序崩溃。如果每次中断都要从头开始,用户体验会很差。所以需要断点续跑的能力。

实现方式是把 agent 的状态定期持久化,包括对话历史、已执行的操作、当前进度。中断后重新启动时,从上次的状态恢复。这要求 agent loop 的设计是状态可序列化的,不能有太多内存中的临时状态。

提示:状态持久化不要存太频繁,否则 IO 开销大;也不要存太少,否则丢失太多进度。我一般每完成一个工具调用就存一次,这样最多丢失一轮的进度。

6.5 性能优化与成本控制

coding agent 的 token 消耗是实打实的成本。优化方向有几个:缓存相同的 API 请求结果,压缩提示词和上下文,批处理多个小请求,选择性价比高的模型。

缓存特别有效。很多情况下,agent 会重复读取同一个文件或者执行同一个命令。把结果缓存起来,下次直接返回,既省 token 又省时间。缓存的失效策略可以基于文件修改时间,文件没变就用缓存。

成本控制还需要预算管理。给每个任务设定 token 上限,超过就停止并提示用户。或者给每天的使用量设一个总额,防止意外消耗。这些策略需要在产品层面考虑,而不仅仅是技术实现。

7. 一些个人体会

做 coding agent 这类工具,最大的感受是模型能力决定了产品下限,工程质量决定了产品上限。模型不够聪明,再好的工程也救不回来;但模型够聪明的情况下,工程上的细节——上下文管理、错误处理、工具设计——直接决定了用户体验是“能用”还是“好用”。

我自己的经验是,不要一开始就追求大而全。先把一个最简单的 loop 跑通,能读文件、能写文件、能执行命令,然后拿它去解决一个真实的小问题。在用的过程中,你会自然发现哪些地方需要改进。很多需求是想象出来的,实际用起来根本不是那么回事。

另一个体会是日志和可观测性极其重要。agent 的决策过程是个黑盒,出了问题很难排查。把每一轮的输入输出、工具调用、耗时、token 消耗都记录下来,出问题时才有据可查。这些日志也是优化提示词和工具设计的重要依据。

最后,这个领域变化很快,今天的最佳实践明天可能就过时了。保持学习,保持动手,比什么都重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进