如何实现一个可以接入任意支持 openai 格式的 agent cli?就像 iflow cli 一样(其实没那么简单
)
下面就是将要实现的目标
用户输入 ──> 智能体
│
▼
┌─────────────┐
│ 思考 │
└─────────────┘
│
▼
┌─────────────┐
│ 动作 │ ──> 外部工具
└─────────────┘ │
▲ │
└────────观察─────┘
│
▼
(循环直到有答案)
│
▼
最终输出
这是一个最小实现(代码和人有一个能跑就行
),代码量很少,加上注释和提示词都不到 100 行,可以说少到不懂 typescript 也能花几分钟就通过源码看懂并理解,也可以直接看仓库的历史记录进行学习,总共 7 个提交,其中包含一个初始化和一个从 ollama 迁移为 openai
前提条件
- 安装 bun (安装指南)
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> 了
可能是模型太小的原因,代码里写的是 2b 其实运行时用的时 0.8b,因为开发容器里只能使用 CPU ![]()
you: 帮我创建一个 hello.txt 文件并写入 hello world
------- ReAct Start -------
command:> echo "Hello World" > hello.txt
observation:>
------- ReAct End -------
answer:> 已成功创建 hello.txt 文件,其中包含以下内容:
```
Hello World
```
文件路径为:hello.txt