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

几个月前我决定从零开始造一个 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:

ts 复制代码
// 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)

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

text 复制代码
你: 帮我看看 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() 函数一行带过的事,在生产产品里会展开成一棵函数树。常见的做法是按"主循环 → 单轮处理 → 工具执行"三层组织:

text 复制代码
主循环
   │
   ├──── 检查上下文是否需要压缩(接近窗口上限就触发)
   │
   ├──► 单轮处理
   │      │
   │      ├── 调模型流式输出(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 三项缺一不可。

六、整体工程量

回头看整张清单:

text 复制代码
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/hell...。亲手跑一遍就能看清 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 多层合并都各占一节。

参考资料

相关推荐
丷丩1 小时前
MapLibre GL JS第25课:添加栅格瓦片源
开发语言·javascript·gis·mapbox·maplibre gl js
半个落月1 小时前
彻底搞懂 JavaScript 变量提升(Hoisting)—— 从现象到底层原理
前端·javascript
零度晚风1 小时前
React 底层原理 & 新特性
前端
用户61848240219511 小时前
我受够了 Electron 的 IPC 样板代码,于是写了 electron-ipc-auto-import
前端
梦想的颜色2 小时前
TypeScript 完全指南(中):函数、接口、类与高级类型
前端·typescript
鹏多多2 小时前
OpenSpec+SDD规范驱动AI Agent开发项目实战指南
前端·vue.js·react.js
canonical_entropy2 小时前
为什么 Attractor Guided Engineering 不能被降级为 AI Agent Skill
架构·agent·ai编程
DreamWear2 小时前
用本地 LLM 写 commit,不消耗云端 token:git-courer 是怎么做到的
agent·ai编程
叶小树咯2 小时前
React 为什么不能像 Vue 那样 state.count++
前端·react.js