Skip to content

如何从零开始实现一个 AI Agent CLI #54

Description

@woai3c

几个月前我决定从零开始造一个 AI Agent CLI——一个能像 Claude Code / Codex CLI / Gemini CLI 那样在终端里读代码、执行命令、修改文件的工具。

一个能演示核心循环如何运作的最小 demo,只有 50 行代码。它能在终端里跟用户对话、按需调用工具读文件。但 demo 也仅限于演示循环本身——要让 agent 跑在生产环境里,外面还要包一层工程框架:上下文压缩、权限模型、流式输出、错误恢复等等。这篇文章讲的就是如何把这一层外围工程拆开来看——一个 AI Agent CLI 从「能跑」到「能托付任务」之间,到底要补齐什么。

一、先看一个 50 行能跑的最小 agent

直接上代码。下面这段 TypeScript 用了 Vercel 的 AI SDK 和 DeepSeek 的模型,一共 50 行左右,已经是一个能在终端里和用户对话、能调用工具读文件的 agent:

// hello-agent.ts
import { deepseek } from '@ai-sdk/deepseek'
import { streamText, stepCountIs, tool } from 'ai'
import { z } from 'zod'
import fs from 'node:fs/promises'
import readline from 'node:readline'

const readFile = tool({
  description: '读取一个文本文件,返回完整内容',
  inputSchema: z.object({
    path: z.string().describe('要读取的文件路径'),
  }),
  execute: async ({ path }) => {
    return await fs.readFile(path, 'utf-8')
  },
})

async function main() {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
  const ask = (q: string) => new Promise<string>((r) => rl.question(q, r))
  const messages: Array<{ role: 'user' | 'assistant'; content: any }> = []

  for (;;) {
    const input = (await ask('\n你: ')).trim()
    if (!input || input === 'exit') break
    messages.push({ role: 'user', content: input })

    const result = streamText({
      model: deepseek('deepseek-chat'),
      messages,
      tools: { readFile },
      stopWhen: stepCountIs(10),
    })

    process.stdout.write('助手: ')
    for await (const chunk of result.fullStream) {
      if (chunk.type === 'text-delta') process.stdout.write(chunk.text)
      else if (chunk.type === 'tool-call') process.stdout.write(`\n  [调用 ${chunk.toolName}(${JSON.stringify(chunk.input)})]`)
      else if (chunk.type === 'tool-result') process.stdout.write(`\n  [返回 ${String(chunk.output).length} 字节]\n助手: `)
    }

    const { messages: newMessages } = await result.response
    messages.push(...(newMessages as any))
  }
  rl.close()
}

main().catch(console.error)

运行起来对话大致是这样的:

你: 帮我看看 package.json 里有哪些依赖
助手:
  [调用 readFile({"path":"package.json"})]
  [返回 N 字节]
助手: 你的 package.json 里有以下依赖:ai、@ai-sdk/deepseek、zod ...

在这里插入图片描述

留意几件事。代码里没有任何地方写「如果用户问 package.json 就读它」、要不要调 readFile、调一次还是多次、什么时候停下来用自然语言回答,这些全部由模型自己决定。我们只是给它一个工具,然后开一个 while 循环。

Agent loop 的本质就是这样:while 循环 + tool use 协议。主流 AI CLI 的内核都是这个形态。区别在于:真正的产品在这 50 行代码外面,包了几千行甚至几万行的工程代码。

二、demo 与生产级产品之间,差的是什么

demo 和生产级产品之间的差距非常大,它只能用来理解 agent loop 的概念,无法用于生产环境。我们可以挑 6 个典型场景来看看它们的差距:

场景一:模型说「我帮你删一下这几个文件」,然后真的删了。 demo 里的 execute 没有任何确认环节——模型一发起调用,工具就立刻执行。假如这条命令换成 rm -rf node_modules,文件就直接全删了。生产级产品需要权限系统,区分读和写,让用户在写操作之前看到将要执行的命令再决定要不要放行。

场景二:用户按 Ctrl+C 想停下来,但是 shell 子进程没退出。 demo 里的 for await 循环被强行中断后,API 请求停了,但工具发起的子进程(比如 npm install)还在后台运行。这是常见 bug——用户以为停了,但 CLI 还在占用 CPU、占用网络、写文件。要让 Ctrl+C 真正中断整条调用链,需要把 AbortSignal 从 Esc 按键一路下传到 execa 的 cancelSignal,再到 shell 子进程的 SIGKILL,中间任何一层断了都不行。

场景三:多轮对话到了第 30 轮,请求就开始报错。 messages 数组每一轮都追加新消息,每次请求都要把所有历史消息重发给模型。另外工具结果消耗的 token 也很多——readFilegrep、shell 命令的 stdout 几轮累积下来,导致 200K 上下文很快被填满。demo 既不压缩也不截断上下文,某一轮请求就会被供应商以 context length exceeded 拒绝,对话只能从头开始。

场景四:模型陷入 doom-loop(反复重试同一个失败动作的死循环),导致 token 费用一直在涨。 demo 里那行 stopWhen: stepCountIs(10) 是粗粒度兜底——agent loop 最多跑 10 步就会强制结束。但模型在 10 步内有可能反复尝试同一个工具:调 readFile('a.txt') 失败 → 调 readFile('./a.txt') 失败 → 调 readFile('/a.txt') 继续失败... 每一步都是一次 API 调用,每次都把越来越长的 messages 发回去。要让 loop guard(循环防护)真正阻断这种重复,需要在更细的粒度上工作——识别「同一个工具加同一组参数」的重复调用,到达阈值后合成一条引导消息让模型换思路。

场景五:让 agent 在一个 8000 行的代码库里工作,它只能逐个 readFile 翻文件。 demo 里只有 readFile 这一个工具。但实际开发中,agent 还需要更多工具:grep 搜索代码内容、glob 按文件名找文件、writeFile / edit 修改代码、bash 执行测试、webFetch 查文档。每加一个工具,都要单独打磨它的描述(description)措辞、参数 schema 和错误返回格式。description 写得不够精准,模型在功能相近的工具之间就会混淆——比如该调 readFile 的时候反而调了 grep

场景六:用户问「我们上次聊到哪里了」,agent 没有上次会话的任何记忆。 demo 退出后, messages 数组只在内存里,没有持久化到磁盘,下次启动就是全新会话。要让对话能跨进程恢复,需要把对话历史持久化(JSONL 单文件 transcript 是常见做法),并提供 /resume 入口加载历史。再进一步还需要构建记忆系统——后台提取器从对话里自动提炼用户偏好和项目背景,整理成结构化条目,下次启动注入到 system prompt。

我们还可以继续列更多的场景:多模态附件怎么处理、子任务怎么委派给一个独立 context 的 sub-agent、Plan Mode 怎么做「只读探索 → 用户审批 → 写操作执行」的状态机、知识系统怎么从多个 AGENTS.md 文件分层合并...

但这些都不是 LLM 能力的问题,是工程问题。50 行 demo 缺的不是更聪明的模型,而是外面那一层能让模型在真实环境里稳定运行的工程能力。

三、一个新视角:AI 工程的三个层级

模型只是个发动机。要让它在代码库里完成任务,外面要套一整套工程。这套工程分三层,跟最近几年话题热度的次序一致:2022-2023 提示词工程(Prompt Engineering)成为研究热点,2024 上下文工程(Context Engineering)独立成概念,2026 驾驭工程(Harness Engineering)正式独立成术语。按工程师介入位置由近到远来看看具体的区别:

层级 工程师介入位置 主要工作
提示词工程(Prompt Engineering) 字符串 写 system prompt、tool description、Chain-of-Thought 引导
上下文工程(Context Engineering) 数据 决定哪些内容进入 context window、怎么压缩 / 截断 / 选择
驾驭工程(Harness Engineering) 代码 搭建调用模型的整套系统——agent loop、工具、护栏、用户交互

这三层不互斥,很多机制都跨两层甚至三层——比如 prompt caching 同时涉及上下文工程和驾驭工程,loop guard 同时涉及提示词工程和驾驭工程。判断一个机制「主要在哪一层」,对理解它的设计取舍有帮助。

提示词工程:2022-2023 成为研究热点

提示词工程关心的是「怎么说话」——用什么样的 prompt 让模型理解任务边界、按什么格式输出。2022 年 ChatGPT 上线后这一层迅速成为显学,学术界涌现了一批 prompting 技巧:

  • Chain-of-Thought(CoT):让模型「先想再答」,在推理任务上准确率显著提升
  • Self-Consistency:多次采样取多数,进一步压低误差
  • ReAct:「思考 - 行动 - 观察」三步交替循环,给 tool use 类任务打下范式基础
  • Tree of Thoughts:每步推理生成多个候选思维构成一棵树,用 BFS / DFS 搜出最优路径——适合 Game of 24 这类需要预判和回溯的题

这股 prompting 研究热在 2023 年到顶。2024 年起调 prompt 的效果就明显减弱——主流模型给个粗略提示也能听懂意图,反复打磨 prompt 也不见多少提升。但提示词工程没消失,只是从「主战场」退居二线——一个工具的 description 写不到位,模型还是用不起来。当年那些技巧也没白学——都被新一代模型在训练时学会了,今天的模型不用再特意提示「先想再答」,这套推理早就内置了。

上下文工程:2024 才独立出来

上下文工程关心的是「让它看到什么」——哪些内容进入 context window、按什么顺序、要不要先压缩。

这一层从提示词工程里独立出来比较晚。2025 年 6 月,Shopify CEO Tobi Lütke 先在 X 上发了一句「我更喜欢 context engineering 这个术语」(6 月 19 日),几天后 Karpathy 转推背书并补了一句「LLM 是 CPU,context window 是 RAM」(6 月 25 日),这两条推文把 context engineering 带进了更多开发者的视野。Anthropic 工程博客随后专门写了一篇《Effective context engineering for AI agents》。背景是 context window 从 4k 扩展到 200K 甚至 1M 之后,大家发现一个反直觉的事实:「放进去更多」并不等于「模型读到更多」。

2023 年的 Lost in the Middle 论文揭示了这件事——模型在长 context 上性能呈 U 型曲线,中段最弱。100K tokens 全放进 context 后,模型对开头和结尾的内容记得很清楚,但中间那一大段经常「忘记」。光放进去不够,得主动管理:

  • 哪些信息保留在 context 里,哪些过滤掉
  • 长内容怎么截断,保留头部还是尾部
  • 历史对话超长怎么压缩
  • 怎么让 prompt cache 命中(前缀字节稳定是前提)

再加一层背景:token 在多轮对话里有累计成本(API 计费按 token 算),每一轮对话都要把整段历史发出去,延迟和 cache 命中率也会跟着波动。一个真实运行的 agent,第 10 轮、第 30 轮、第 100 轮的 context 占用、cache 命中率、响应延迟变化轨迹完全不同。上下文工程要管的不是单轮的决策,而是要让 context 在长会话里不溢出、cache 一直命中、延迟保持稳定。

到 2024 年,这一层已经有了自己的工具链——截断策略、压缩流水线、cache 命中保护、知识注入顺序,每一项各有取舍。

驾驭工程:2026 才正式独立成话题

这个词出现得最晚——2026 年 2 月由 HashiCorp 联合创始人 Mitchell Hashimoto 在博客里正式独立成术语(核心公式:Agent = Model + Harness)。Anthropic、OpenAI 等团队跟进得比较多。

「Harness」字面意思是「挽具」——给原本只会预测下一个 token 的模型套上一身能完成任务的骨架。agent loop、工具系统、护栏、错误恢复、用户交互、评测钩子——所有让 LLM 在生产环境里完成任务的外围工程,都归在这一层。

驾驭工程的边界其实比 agent loop 宽得多。前面第二节列的 6 个场景全都属于这一层,再加上流式输出怎么拆解成 UI 能消费的事件、网络抖动时怎么重试、prompt cache 怎么保持命中、跨平台终端兼容性等等更细的工程——每一条单独看都不起眼,但加起来就是「能跑的 demo」和「能用的工具」之间的差距。提示词工程做单点质量,上下文工程做信息密度,驾驭工程做整体可用性。

回头看那 50 行 hello-agent,它已经把提示词工程和上下文工程的最小形态实现了——工具有 description、messages 数组维护了上下文。它缺的几乎全是驾驭工程的东西:UI、权限、错误恢复、loop guard、压缩、子 agent、知识系统。

四、应用层 vs 模型层:价值在向外层转移

过去三年,AI 领域大部分钱和注意力都在模型层——训练更大的模型、更强的 reasoning、更长的 context。这是合理的,发动机决定车的上限。但 2024 年起边际收益明显递减:GPT-4 到 GPT-5、Claude 3.5 到 Claude 4,每一代的提升仍然存在,但已经不是早期那种「换一代模型整个产品形态都要重做」的跨越式提升——基线能力对大多数应用已经够用。

反过来应用层的差距越来越明显。同样调 Claude API,一款专门做编程的 agent 跟自己写个聊天框直接调 Claude API,体验完全不同——区别不在模型,在外面那一层工程。模型公司自己也意识到了这一点,Anthropic 做 Claude Code,OpenAI 做 Codex CLI,Google 做 Gemini CLI,都是在模型之上补工程层。

对开发者来说,驾驭工程是当前最值得投入精力的方向。提示词工程已经有大量博客和课程在讨论,上下文工程也有相对成熟的方法论,但驾驭工程还没有「标准答案」——怎么设计 agent loop、怎么切工具粒度、怎么做 loop guard,每家产品的做法都不一样,这也是产品之间拉开差距的地方。

五、驾驭工程具体在解什么问题

下面这几节每个都是一个独立的工程领域,背后都有自己的设计取舍。

Agent Loop 引擎

50 行 demo 里 streamText() 函数一行带过的事,在生产产品里会展开成一棵函数树。常见的做法是按"主循环 → 单轮处理 → 工具执行"三层组织:

主循环
   │
   ├──── 检查上下文是否需要压缩(接近窗口上限就触发)
   │
   ├──► 单轮处理
   │      │
   │      ├── 调模型流式输出(streamText)
   │      │
   │      ├── 把流式 chunk 分发给 UI
   │      │     ├── text-delta   → 文本片段写入终端
   │      │     ├── tool-call    → 显示「调用 readFile(...)」
   │      │     └── tool-result  → 显示「返回 N 字节」
   │      │
   │      └── 把这一轮的 messages 累积进对话状态
   │
   ├──► 检查 finishReason
   │      ├── 'tool-calls'  → 执行工具 → 回填结果 → 继续下一轮
   │      ├── 'length'      → 注入 resume 提示 → 继续
   │      └── 'stop' / 错误 → 结束
   │
   └── 用户按 Esc → AbortSignal 一路下传 → 整条调用链中断

Agent Loop 引擎要解决的核心问题包括:把模型流式输出拆解成 UI 能消费的事件、把上一轮的 tool_result 配对返回、在出错时分类处理、在用户按 Esc 时把整条调用链彻底中断。

实际工程里错误有很多种,按处理策略大致可以归成四类。一类是可以静默重试的:429 限流、503 维护、网络抖动,等几秒重试即可。一类是重试也是同样结果,要立刻报给用户并提示下一步:401 鉴权失败提示更换 key、402 余额不足提示充值、422 内容审核提示改写内容。一类是不能当错误处理的,例如 context 超限要压缩上下文,不能直接中断会话。还有一类是 demo 没有覆盖的:上一轮历史里有孤立的 tool_call 缺少对应的 tool_result,下一次发请求供应商会直接拒绝,要在发送前主动修复工具调用的配对关系。

工具系统:模型的「手脚」

模型不会自己读文件、执行命令、查询网络,凡是涉及磁盘、网络、shell 的操作,都需要工具系统去完成。模型在 messages 里发出 调用 readFile('package.json') 这样的指令,工具系统接收后把文件读取出来,再把内容塞回 messages 让模型读到。

执行本身没多少难度,真正费心思的是几处设计取舍:

粒度划分。 给模型一个 bash 工具让它自己写命令是最灵活的方案,但模型可能写出意想不到的命令,安全性和可预测性都差。换成 readFile / writeFile / grep / glob 这种细粒度工具,行为可控,但又覆盖不到 bash 那样的灵活操作。Claude Code、Cursor 等主流产品基本都做混合:细粒度工具承担日常操作,bash 留给特殊场景。

错误返回格式。 工具失败时,错误信息要写回 messages 让模型理解。这里要注意一下错误消息的尺度——如果消息内容过于简略,模型可能不知道该怎么修;如果消息内容过于详细,又会浪费 token。所以实际工程里通常 error code、一句解释、关键上下文这三样齐了就够用。

结果截断。 grep 一次返回 5000 行的情况并不少见,完整塞进 messages 不现实。可选策略包括按行截断保留头尾、按字节截断保留头部、或在中间插一条 [...truncated, N lines omitted] 让模型知道结果被处理过。具体怎么选取决于工具特性。

Esc 信号的传递路径。 这一项最容易出问题。AbortSignal 要从用户按下 Esc 那一刻起,沿着「SDK 流式接收 → 工具执行 → 子进程封装 → 实际子进程」一路下传,每一层都要把信号正确转交给下一层:

  • 流式接收层:SDK 的 streamText({ abortSignal }) 收到信号停止读取模型输出
  • 工具执行层:拉起子进程的工具(比如 shell 工具)把信号转给下面的子进程封装
  • 子进程封装层:通常用 execa 之类的库实现,调用 spawn({ signal }) 把信号挂在 Node child_process 上
  • 实际子进程:触发 SIGKILL,把整棵子进程树清理掉

中间任意一层断开,子进程都会继续在后台运行——用户按下 Esc 之后 CLI 看似已经停止工作,实际上子进程还在写文件、查询网络、执行命令。

子 agent:把上下文当成稀缺资源

子 agent 是一个相对新的设计点,跨在驾驭工程和上下文工程之间。

一个具体场景:用户问「这个项目里有哪些地方在用 legacy_auth 这个函数」。直观做法是让主 agent 调用 grep 工具在代码库里搜出所有包含 legacy_auth 的文件,然后对每个命中的文件调用 readFile 读出全文,再逐个判断该函数在那里的具体用法。问题是,这种探索性操作会把大量工具调用记录和文件内容留在父对话里,把后续做修改时需要的 context 提前占满。

子 agent 的思路是:把探索性子任务交给一个跑在独立 context 里的 sub-agent,子 agent 自己完成 grep + 阅读 + 判断 的整个流程,只把最终结论回传给父对话。从父对话视角看,相当于「问了一个问题,得到了一段总结」,中间几十次工具调用不进入主上下文。

这么做的好处首先是上下文隔离——探索过程中那些大量的工具调用和文件内容留在子 agent 内部,不挤占主对话的 context。其次是角色可以专精,探索型、规划型、审查型的 sub-agent 各自有自己的系统提示词和工具白名单。另外父子 agent 共享同一个 abort signal,用户 Esc 一次整棵任务树一起停。

设计子 agent 要解决的问题包括:父子之间用什么协议传递任务和结论、token 用量和权限信号怎么回流给父级、子 agent 在 Plan Mode 下要不要也强制只读、递归调用要不要禁止。多数实现选择禁止子 agent 再启子 agent,避免无限分裂。

从 token 成本看,子 agent 跑探索任务时,内部可能调几十次工具、产生数万 token 的工具结果,但回传给父对话的只有最终一段摘要——可能就几百 token。父对话里相当于「花了几百 token 换到一个结论」,子 agent 内部那几十次调用都封闭在自己的独立 context 里。在多轮长会话里这种「探索成本不污染主对话」的复利效果很明显,单纯的函数封装做不到。

终端 UI:最容易被低估的一层

AI CLI 的终端 UI 需要同时承载若干交互形态:上方是滚动的对话历史,下方是固定的输入框,中间是流式打字效果。模型回复需要渲染 Markdown,权限敏感操作会触发确认对话框,CJK 字符、emoji、组合字符这些宽字符体系也都要支持。单独来看,每一项的实现难度都不大,但叠加起来就会发现有几处明显的难点:

布局重排。 现代 TUI 框架里 Ink 直接复用了 React Native 的 Yoga 布局引擎,问题是 Yoga 对宽字符(CJK、emoji、组合字符)的列宽测算与终端实际渲染并不一致——一个汉字在终端里占 2 列,Yoga 默认按 1 列计入,整层 layout 计算都偏离实际列数。中文环境下流式输出每接收一个 chunk 都要重新跑一次布局,每次结果都带着累积偏差,具体表现为:上半屏文本的换行位置在每一帧之间跳动,下方固定输入框因为前面文本的行数被算错而上下漂移,光标也会在新旧两个候选位置之间闪烁。一段几百字的中文回复流式输出完,整个 UI 给用户的观感就是持续抖动。

dynamic region 锚点冲突。 框架的局部重绘机制依赖终端的 cursor save 寄存器记录起始位置。一旦代码中有其他路径直接调用 process.stdout.write,该寄存器会被覆盖,重绘锚点随之错位。

同步更新协议的兼容性。 DEC private mode 2026 / BSU / ESU 这一组同步更新协议在新版 iTerm2、WezTerm、Windows Terminal 中已支持,但旧版 PowerShell、cmd 以及低版本 SSH 客户端尚未实现,需要在框架层提供 fallback。

emoji 宽度对齐。 同一个 emoji 在不同终端中的列宽测算存在差异——部分按 2 列计算,部分按 1 列计算。这会导致版面对齐失效,需要维护一份 glyph 宽度表作为回退基准。

实际工程中,多数产品最终会绕开 Ink 等高层框架,直接操作 ANSI 转义序列,自行实现 cell-level diff 渲染。Claude Code、Codex CLI、Gemini CLI 的终端层都做了类似的工程取舍。

Prompt caching:单 session 里最大的成本杠杆

设想一个具体场景:你在用一个 AI 编程助手,它的 system prompt 有 8000 字,里面写着工作风格、可用工具、安全规则。每输入一句话,后端发给模型的请求都要带上这 8000 字 + 之前几轮对话 + 你这一句新输入。

问题是:这 8000 字 system prompt 从第一轮到第三十轮一字没变。模型每收到一次请求都要把它从头读一遍、算一遍,第二轮、第三轮做的工作跟第一轮完全重复,这部分重复计算最终会按 input token 全额结算到用户头上。

Prompt caching 就是针对这种浪费的服务端缓存方案。第一次收到这 8000 字 system prompt 时,供应商把模型处理它时算出的中间结果(具体是 Transformer 注意力层产生的 Key/Value 矩阵,行业里简称 K/V)存进缓存;下一次同样的前缀进来,跳过这段计算,只算新加的那句话。跳过的 token 按命中价结算,Anthropic / Google / Alibaba 显式缓存都在 input 价格的 10% 左右,DeepSeek 更激进只收 2%-3%,Moonshot 在 17% 上下——一段长 system prompt 在几十轮对话里反复复用,input 计费可以从 100% 压到 30% 甚至更低,这是单个 session 里最大的成本杠杆。

命中规则严格:前缀必须字节级一致。假如在 system prompt 里插入一段 Current time: 2026-04-26 16:30:42 这样的时间戳,会导致整段缓存作废,已积累的所有历史前缀也跟着按 100% 重新计费。

各家供应商的接入协议存在差异:

  • Anthropic:请求里显式设置 cache_control breakpoint,缓存命中范围覆盖 breakpoint 之前的完整前缀。
  • OpenAI 兼容端点(DeepSeek / Moonshot / Alibaba 等):服务端自动维护前缀缓存,识别到稳定前缀即自动命中(仍按命中费率结算,只是不需要在请求里额外声明)。
  • Google:通过独立的 cachedContent API 接入,按存储时长和命中量分别计费。

跨供应商运行的 agent 需要在请求发送层屏蔽这些差异,让上层 agent loop 不必感知当前对接的是哪一家。

另一处差异在最小缓存粒度:Anthropic 在 Claude 4.x 系列(Sonnet 4.5 / Opus 4.5 / Haiku 4.5)上把起算线统一提到了 4,096 token,老版本 Claude 3.5 Sonnet 和 Opus 3 是 1,024,Claude 3.5 Haiku 是 2,048。前缀长度不达标则无法写入缓存。这一约束让 system prompt 的长度本身成为一个工程取舍点——太短触发不了缓存,过长则增加首次写入成本(Anthropic 默认 5 分钟缓存的写入费率比常规调用高约 25%,可选的 1 小时缓存高约 100%)。

知识与记忆:跨会话的上下文延续

agent 默认是无状态的——每次启动都是一个空白会话,对项目背景和用户偏好一无所知。要让它在长期使用中持续累积项目认知,需要把这两类信息从单次 session 里抽出来做持久化存储。实际做法通常分两条线:一条是开发者主动维护的显式知识,另一条是 agent 在后台自动提炼的隐式记忆。

显式知识:AGENTS.md 约定。 这是开源社区在 2025 年逐步形成的公开规范,目前由 Linux Foundation 下的 Agentic AI Foundation 托管,已被 OpenAI Codex、Cursor、Aider、Windsurf、Devin 等多家产品原生支持。开发者在项目根目录的 AGENTS.md 里写明代码风格、技术栈、模块划分、常用命令等项目级背景,agent 启动时把它注入到 system prompt 的固定位置,效果上等同于让 agent 在动手前先读一遍项目说明书。

多层合并。 实际工程里这个注入要按层级组合:用户主目录下的全局偏好(个人风格,跨所有项目生效)→ 项目根目录的团队约定(共享给所有协作者)→ 子目录的局部覆盖(monorepo 里某个子包可以单独覆盖父目录的设置),子层级优先级高于父层级。再额外加一层 .local 文件(被 .gitignore 排除)承载个人在本仓库的私有偏好,不污染团队共享配置。同一份 agent 实现在不同项目、不同开发者手上能呈现出完全不同的工作方式,不需要每次启动时重新声明上下文。

自动记忆:后台 extractor。 每轮对话结束后,后台跑一个轻量提取器,从对话内容里识别可复用的事实并按类别归档。常见的分类维度包括用户偏好(「注释写简略一点」)、项目背景(「这个仓库用 vitest 跑测试」)、协作习惯(「涉及数据库改动前先让我确认」)、外部引用(「监控面板在 grafana.internal/...」)等。提取出来的条目按一定的格式持久化到本地存储,下次启动时跟显式知识一起注入 system prompt。

两套机制配合使用之后,agent 在同一个项目上跑得越久,对项目结构和用户习惯的了解就越准确。那些让人感觉繁琐的事情——反复问已经说过的背景、再次建议已经被否决过的方案——会随着使用时间拉长逐渐消失。

生产化护栏

最后一组是决定 agent 上生产环境后能不能稳定运行的护栏机制。下面三道分别对应三类典型故障——误删文件、token 成本失控、对话陷入死循环。

权限模型。 核心思路是把读和写分开管控。一种常见的三档设计是:default(读操作直接放行、写操作弹确认对话框)、plan(强制只读,agent 只能探索代码、产出方案,所有写操作完全禁用)、acceptEdits(信任模式,所有写操作自动放行不再确认)。plan 这一档对应的工作流是「探索 → 出方案 → 用户审批 → 执行」,把模型的不确定性挡在写操作之前。

上下文压缩。 context 占用在多轮对话里会持续累积,溢出之前必须主动管理。一种常见的分级做法是:

  • 轻量压缩:O(n) 预清理,删除一些已经不再承载信息的中间消息(例如被截断后只剩外壳的工具结果)。
  • 主动压缩:context 占用到 70-80% 时,把旧轮对话合并成一段摘要,腾出空间继续。
  • 紧急压缩:context 即将溢出时强制截断旧消息。
  • 手动 /compact:用户在合适的对话节点主动触发。

每一级触发的阈值、保留范围、对 prompt cache 命中的影响都要精细设计——一次过早的主动压缩会让本可以命中的 cache 失效,反而加重 token 成本。这里还有一道隐性约束:被压缩进摘要的 tool_call / tool_result 必须配对清理干净,否则下一轮请求里出现一个孤立的 tool_call 引用,供应商会直接以 4xx 拒绝。

Loop guard。 模型陷入死循环是真实发生的故障,不是假设场景。一个典型场景是 PowerShell 下的 shell 命令引号转义问题——模型试一遍失败,读了 stderr 之后重试同样的命令,又失败,再读 stderr 再重试 …… 每一轮都消耗 token、占用 context,永远拿不到正确答案。当前 LLM 在自回归生成 + 低温度采样下容易陷进这种循环:prompt 里反复出现「调 X 失败 → 我再调 X」会强化「调 X」这个 pattern,而模型本身没有元认知,并不知道自己上一轮做的是同一件事。

工程上的应对是给每次工具调用算一个稳定的指纹(典型做法是对工具名和参数做哈希),追加到一个固定的滑动窗口里。每次新调用进来,数一遍窗口里同指纹出现的次数,达到不同阈值触发不同动作。常见的双阈值设计是:软阈值(比如 3 次)注入一条合成 tool-result「这个调用已经失败过多次,换条思路」,让模型在 prompt 层面自己选择换条路径;硬阈值(比如 5 次)才弹对话框中断流程,由用户接管决策。双阈值给模型留一次自我纠正的机会,避免一次重复就硬切打断用户体验。

合成消息本身的措辞也是提示词工程——同样表达「换条思路」,措辞到不到位决定模型是继续硬试同样的参数,还是真正换一条路径。

这三道护栏分别对应一类典型故障:误删文件、token 成本失控、对话陷入死循环。生产级 agent 三项缺一不可。

六、整体工程量

回头看整张清单:

50 行 hello-agent
  → agent loop 引擎(错误分类 / 流式分发 / 配对修复)
  → 工具系统(10+ 工具 + 粒度设计 + 截断策略 + abort 传递)
  → 子 agent(独立 context + 工具白名单 + abort 级联)
  → 终端 UI(cell-level diff + 流式 + Markdown + 跨终端兼容)
  → 权限模型(三档 + plan mode 状态机)
  → 上下文压缩(三级 + cache 命中保护)
  → Loop guard(指纹检测 + 合成消息)
  → 知识与记忆(AGENTS.md 多层合并 + auto-memory)
  → 会话持久化(JSONL transcript + /resume)
  → 思考模式抽象 / 多模态附件 / Plan Mode 状态机 / ...

这就是从一个 50 行 demo 到一个能在真实工作流里跑起来的 AI CLI 之间需要补齐的工程缺口。核心代码量在两万行 TypeScript 量级。对比一个成熟 IDE 或一套云原生平台,这个体量并不大——一位有经验的工程师带 3-5 人的小团队,几个月可以做出第一版能用的产品。

两万行的工作量分布并不均匀。以我们做的这个开源实现为参考:Agent loop 本体加错误恢复在 1,000 行量级;工具系统和具体工具实现合起来 3,000-4,000 行;终端 UI(cell-level diff、Markdown 渲染、跨终端兼容、流式输出节流)单独占 2,000-3,000 行,是多数实现里被严重低估的一块。剩下的上下文压缩、权限模型、loop guard、知识系统、子 agent、会话持久化各自相对独立,但都要在主循环上挂载明确的对接点。

2025 年开始进入这条赛道的小团队明显增多——模型层的核心能力已经够了,剩下的瓶颈基本都在工程层。这一层的成熟度还在快速迭代,比如 AGENTS.md 这种跨产品的社区约定就是 2025 年才稳定下来的,再往前一年各家厂商还各自用 CLAUDE.md / .cursorrules / .aider.conf.yml 这些命名,互不兼容。

七、如果你想自己动手

如果你也想自己造一个 agent CLI:

先把 hello-agent 跑起来。 demo 代码放在 github.com/woai3c/hello-agent。亲手跑一遍就能看清 agent 的核心结构——一个 while 循环、一组工具,模型自己决定调什么、调几次、什么时候停下来回答。本文讨论的所有工程问题,都是在这个最小循环外面一层一层加出来的。

挑一个你最关心的问题深入。 不必从头读到尾,找一个最具体的问题先切进去。如果你最关心模型的边界在哪里,就从 agent loop 引擎和工具粒度入手;如果最担心 agent 失控(误删文件、token 账单爆炸),就从权限模型和 loop guard 入手。

读真实的开源实现。 Aider、OpenHands 都开源,每家在 UI、工具集、工作流上的取舍不一样,对照着读源码能看出不同设计思路。提醒一句:这些项目代码量在数万行级别,UI、工具、配置层互相牵扯,建议先选好一个具体问题(比如「Esc 是怎么把 shell 子进程一并杀掉的」),顺着这条主线从用户输入一路读到子进程退出,读完再换下一个问题。比从 main 函数一行行读下去高效得多。

想看每个机制在代码里具体怎么落地,可以看我写的一本小册:《从零打造一个 AI Agent CLI》。这本书以开源项目 X-Code CLI 为主线,按本文「提示词工程 / 上下文工程 / 驾驭工程」的分法逐节展开。本文受篇幅所限只能讲到机制层,书里则把每个机制具体怎么在代码里落地展开来讲——agent loop 引擎、cell-level diff 渲染、子 agent 上下文隔离、loop guard 指纹算法、AGENTS.md 多层合并都各占一节。

参考资料

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions