渐进式教程:从零开始构建一个可执行命令的 Agent CLI

如何实现一个可以接入任意支持 openai 格式的 agent cli?就像 iflow cli 一样(其实没那么简单 :rofl:)

下面就是将要实现的目标

用户输入 ──> 智能体
            │
            ▼
      ┌─────────────┐
      │    思考      │
      └─────────────┘
            │
            ▼
      ┌─────────────┐
      │    动作      │ ──> 外部工具
      └─────────────┘        │
            ▲                │
            └────────观察─────┘
            │
            ▼
      (循环直到有答案)
            │
            ▼
        最终输出

这是一个最小实现(代码和人有一个能跑就行 :nerd_face:),代码量很少,加上注释和提示词都不到 100 行,可以说少到不懂 typescript 也能花几分钟就通过源码看懂并理解,也可以直接看仓库的历史记录进行学习,总共 7 个提交,其中包含一个初始化和一个从 ollama 迁移为 openai

前提条件


Step 1: 调用模型

创建 index.ts,验证模型连接是否正常,只需要设定 api 和 key,再填上模型就能立即对话

import OpenAI from 'openai'

const client = new OpenAI({
    baseURL: 'http://127.0.0.1:11434/v1',
    apiKey: 'ollama',
})

const response = await client.chat.completions.create({
    model: 'qwen3.5:2b',
    messages: [{ role: 'user', content: '你好' }],
})

console.log(response.choices[0]?.message.content)

运行结果:

你好!有什么我可以帮你的吗?比如需要信息查询、写作帮助,还是其他需求?😊

Step 2: 处理用户输入

只需要加个循环,以及接收用户输入,就能轻松进行多轮对话

import OpenAI from 'openai'

const client = new OpenAI({
    baseURL: 'http://127.0.0.1:11434/v1',
    apiKey: 'ollama',
})

const user_input = await console[Symbol.asyncIterator]()

while (true) {
    process.stdout.write('\nyou: ')
    const { value } = await user_input.next()

    const response = await client.chat.completions.create({
        model: 'qwen3.5:2b',
        messages: [{ role: 'user', content: value.trim() }],
    })

    console.log(`llm: ${response.choices[0]?.message.content}`)
}

运行结果:

you: 你好
你好!很高兴为你服务。有什么我可以帮助你的吗?或者你有其他问题想问?

Step 3: 保存对话历史

引入 messages 数组保存对话上下文,这样进行对话时就能拥有上下文的联系了,到现在为止,其实已经实现了一个和网页对话差不多的聊天工具了,这里添加了 reasoning_effort 是用来降低推理长度,让模型更快的回答,因为推理模型老是想一堆东西

import OpenAI from 'openai'
import type { ChatCompletionMessageParam } from 'openai/resources'

const client = new OpenAI({
    baseURL: 'http://127.0.0.1:11434/v1',
    apiKey: 'ollama',
})

const user_input = await console[Symbol.asyncIterator]()
const messages: ChatCompletionMessageParam[] = []

while (true) {
    process.stdout.write('\nyou: ')
    const { value } = await user_input.next()

    messages.push({ role: 'user', content: value.trim() })

    const response = await client.chat.completions.create({
        model: 'qwen3.5:2b',
        messages: messages,
        reasoning_effort: 'low',
    })

    const content = response.choices[0]?.message.content ?? ''
    messages.push({ role: 'assistant', content })

    console.log(`llm: ${content}`)
}

运行结果:

测试多轮对话的上下文保持

you: 1+1=?
llm: $1+1=2$

you: 再+1
llm: $2 + 1 = 3$所以结果是:**3**。

Step 4: 实现 ReAct 循环

最后一步构建 ReAct (Reasoning + Acting) 循环,到这里为止,就实现了一个可以进行多步思考和工具调用的 Agent 了

这一步是在对话循环中在加一个循环,用于 Agent 执行的内部循环,然后再通过提示词让 LLM 可以按照指定的格式进行输出,这样后续只需要解析 XML 标签就能进行对应的操作,比如提取 shell 命令并执行

XML 标签主要分为以下几个类别

  • <task> 用户的任务
  • <thought> LLM 的思考过程
  • <command> LLM 想要执行的命令
  • <observation> LLM 观察结果,其实就是命令执行后的输出
  • <answer> LLM 的最终回答,当 LLM 觉得任务已经完成了就可以进行回答
// 导入依赖
import { $ } from 'bun'
import OpenAI from 'openai'
import type { ChatCompletion } from 'openai/resources'

const client = new OpenAI({
    baseURL: 'http://127.0.0.1:11434/v1',
    apiKey: 'ollama',
})

const user_input = await console[Symbol.asyncIterator]()
const messages: OpenAI.ChatCompletionMessageParam[] = [] // 存储用户输入和模型回复

// 添加系统提示
messages.push({
    role: 'system', content: `你的目标是完成用户的任务,你必须选择以下的其中一个XML格式进行回复,**一次且仅能输出一个标签**:
- <thought>思考内容</thought>: 先思考要做什么
- <command>命令内容</command>: 需要执行系统命令时输出,内容为纯命令(例如 "echo hello")
- <observation>系统返回结果</observation>: 系统返回的结果,不能自己生成
- <answer>总结内容</answer>: 任务完成时输出总结

重要规则:
1. **每次只能输出一个标签,严禁在同一回复中包含多个标签。**
2. 执行任何命令前,必须先输出 <thought> 标签思考要做什么。
3. 如果你认为需要执行命令,则必须输出 <command> 标签
4. 禁止自己生成 <observation> 标签,这是系统返回的结果。
5. 如果任务已完成,输出 <answer> 标签总结结果。

示例:
user: <task>查看当前目录所在路径</task>
assistant: <thought>我需要查看当前目录所在路径</thought>
assistant: <command>pwd</command>
shell: <observation>/workspaces/tiny-agent</observation>
assistant: <answer>当前目录所在路径为:/workspaces/tiny-agent</answer>
`
})

while (true) {
    // 获取用户输入
    process.stdout.write('\nyou: ')
    const { value }: { value: string } = await user_input.next()

    // 将用户输入的任务添加到消息列表
    messages.push({ role: 'user', content: `<task>${value.trim()}</task>` })

    // 开始 ReAct 循环
    console.log('\n------- ReAct Start -------')
    while (true) {
        // 调用模型
        const response: ChatCompletion = await client.chat.completions.create({
            model: 'qwen3.5:2b',
            messages: messages,
            reasoning_effort: 'low',
        })

        // 将模型回复添加到消息列表
        const content = response.choices[0]?.message.content ?? ''
        messages.push({ role: 'assistant', content: content })

        const thought = content.match(/<thought>([\s\S]*?)<\/thought>/)?.[1]?.trim() // 提取思考内容
        const command = content.match(/<command>([\s\S]*?)<\/command>/)?.[1]?.trim() // 提取命令
        const answer = content.match(/<answer>([\s\S]*?)<\/answer>/)?.[1]?.trim() // 提取总结

        // 思考要做什么
        if (thought) {
            console.log(`thought:> ${thought}`)
            continue // 有思考内容时,继续循环让模型基于思考内容继续回复
        }

        // 执行命令
        if (command) {
            console.log(`command:> ${command}`) // 打印命令
            try {
                const result = await $`bash -c ${command}`.text()
                messages.push({ role: 'user', content: `<observation>${result}</observation>` }) // 将执行结果添加到消息列表
                console.log(`observation:> ${result}`) // 打印执行结果
            } catch (error) {
                messages.push({ role: 'user', content: `<observation>${error}</observation>` })
                console.log(`observation:> ${error}`) // 打印执行失败信息
            }
            continue // 有命令执行时,继续循环让模型根据 observation 决定下一步
        }

        // 总结任务
        if (answer) {
            console.log('\n------- ReAct End -------')
            console.log(`answer:> ${answer}`)
            break
        }
    }
}

运行结果:

这些运行结果都是实际的效果,这个没有进行 <thought> 就直接 <command>:joy: 可能是模型太小的原因,代码里写的是 2b 其实运行时用的时 0.8b,因为开发容器里只能使用 CPU :smiling_face_with_tear:

you: 帮我创建一个 hello.txt 文件并写入 hello world

------- ReAct Start -------
command:> echo "Hello World" > hello.txt
observation:> 

------- ReAct End -------
answer:> 已成功创建 hello.txt 文件,其中包含以下内容:
```
Hello World
```
文件路径为:hello.txt
3 个赞

顶~

赞!

我来学习了~