06 - 让 REPL 复用 Agentic Loop 与 Context
这是一套从 0 到 1 构建 Agent CLI 的分阶段实战教程。 你会沿着 REPL -> Agentic Loop -> Context Builder -> 工具系统 -> 工程化 的路线逐章推进,最终做出一个可运行、可扩展、可发布的完整工具。
技术栈:TypeScript + Node.js/Bun + React Ink + OpenAI/Anthropic API/DeepSeek API/GLM API/Qwen API
仓库在这:hello-agent-cli。作者博客:https://www.riconext.cn/。
- 建议按章节顺序读,每章末有对应分支;
- 仓库地址是 https://github.com/ricoNext/hello-agent-cli/tree/chapter-xx, 每章的代码按照分支存放在仓库中, 分支名称为
chapter-xxx。 - 本章的代码改动会基于上节的代码进行改动,所以你可以直接基于上一节的代码学习,跟着本章的代码一起实现。

前几章已经凑齐三件事:能进 REPL 模式、-p 能跑 Agentic Loop、第五章又往上下文里塞了 Git 和用户规则。
现在 -p 场景下能够进入 Agentic Loop 和 Context Builder,而 REPL 模式下还是进入普通的流式对话中, 并不能使用 Agentic Loop 和 Context Builder 的能力。
所以这一节, 我们把 Agentic Loop 和 Context Builder 的能力抽出来, 让 REPL 和 -p 模式下都能使用。
一、梳理实现思路
之前实现的循环入口是 loop.ts 文件中的 runAgentPipe 函数, 他实现了 for:callModel 的逻辑, 并在 callModel 中判断是否存在 tool_calls, 如果存在则调用 handleToolCalls 处理工具调用, 否则直接打印结果。
看一下之前 runAgentPipe 函数的实现, 这里只展示核心逻辑, 完整代码请参考仓库。
export async function runAgentPipe(opts: {
prompt: string;
model: string;
maxTurns: number;
}): Promise<void> {
// ...
const messages: ChatCompletionMessageParam[] = [
{ role: "user", content: opts.prompt },
];
// 循环调用模型, 直到达到最大轮次或模型不再需要调用工具
for (let turn = 0; turn < opts.maxTurns; turn++) {
const res = await callModel(opts.model, messages);
const choice = res.choices[0];
const msg = choice?.message;
// ...
// 如果有工具调用,则调用工具
if (msg.tool_calls?.length) {
const chunk = await handleToolCalls(msg);
messages.push(...chunk);
continue;
}
// 否则直接打印结果
console.log(msg.content ?? "");
return;
}
// ...
}最后的结果也可以看到, 当大模型判定不再需要调用工具时, 因为是在 -p 模式下, 直接打印了结果, 为了保证 REPL 模式下也能使用,就需要在 REPL 模式下将最后一轮的 assistant 消息保存在对话的上下文中, 而在 -p 模式下, 直接打印结果。
知道了这个最终的目的, 我们来看实现的思路:
- 将
runAgentPipe函数中的循环的核心逻辑提炼成runAgentConversation函数。支持-p和REPL模式。 - 改写
runAgentPipe函数, 让它调用runAgentConversation函数, 继续走-p模式下的逻辑。 - 改写
repl-app.tsx文件, 对话时调用runAgentConversation函数, 继续走REPL模式下的逻辑。
知道要做的事情, 我们来看如何实现。
二、抽离 runAgentConversation 函数, 支持 -p 和 REPL 模式
runAgentConversation 的主要逻辑和 runAgentPipe 类似, 都是循环调用模型, 直到达到最大轮次或模型不再需要调用工具。 他需要两个参数:
initialMessages: 这一轮已经拼好的 OpenAImessages(里头含本轮用户话), 这是一个ChatCompletionMessageParam[]类型的数组。opts: 外加model/maxTurns, 以及onToolRound函数, 用来进入工具Loop时回调给REPL展示信息。
并返回两个值:
messages: 这一轮循环结束后的messages, 依旧是一个ChatCompletionMessageParam[]类型的数组, 包含了这一轮循环的全部对话消息。finalAssistantText: 这一轮循环结束后的assistant消息的文本内容, 提供给-p模式下直接打印。
ChatCompletionMessageParam 是 OpenAI 的类型, 定义了 role 和 content 字段。
先定义 入参 opts 的类型接口:RunAgentConversationOptions
// loop.ts
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
/** 与 `-p`、REPL 共用的执行选项 */
export interface RunAgentConversationOptions {
model: string;
maxTurns: number;
/** 进入工具轮时回调(用于 REPL 展示「正在调用工具」) */
onToolRound?: (info: { toolNames: string[] }) => void;
}下面实现 runAgentConversation 函数:
// loop.ts
/**
* 从当前 OpenAI 消息列表开始跑 Agent 循环,直到返回无 tool_calls 的 assistant 文本或达到 maxTurns。
*/
export async function runAgentConversation(
initialMessages: ChatCompletionMessageParam[],
opts: RunAgentConversationOptions
): Promise<{
messages: ChatCompletionMessageParam[];
finalAssistantText: string;
}> {
// 初始化 working 数组, 复制初始消息
const working: ChatCompletionMessageParam[] = [...initialMessages];
// 循环调用模型, 直到达到最大轮次或模型不再需要调用工具
for (let turn = 0; turn < opts.maxTurns; turn++) {
const res = await callModel(opts.model, working);
const msg = res.choices[0]?.message;
if (!msg) {
throw new Error("模型未返回 message");
}
if (msg.tool_calls?.length) {
// 这里先过滤出工具调用, 并获取工具名称, 用于在 `REPL` 模式下展示
const names = msg.tool_calls
.filter((t) => t.type === "function")
.map((t) => t.function.name);
opts.onToolRound?.({ toolNames: names });
// 调用工具
const chunk = await handleToolCalls(msg);
// 将工具调用的结果添加到 working 数组中
working.push(...chunk);
continue;
}
// 没有工具调用, 则直接将 `assistant` 消息添加到 working 数组中
const text = msg.content ?? "";
working.push({ role: "assistant", content: text });
return { messages: working, finalAssistantText: text };
}
// 如果达到最大轮次, 则抛出错误
throw new Error(`已达到最大轮次 ${opts.maxTurns},停止以防死循环。`);
}working数组用来存储这一轮循环的对话消息。opts.onToolRound用来在进入工具轮时回调给REPL展示信息。handleToolCalls函数没有变化, 这里不展开。- 最后返回
messages用来给REPL模式下展示对话历史, 返回finalAssistantText用来给-p模式下直接打印。
然后就是调整 runAgentPipe 函数, 让它调用 runAgentConversation 函数, 继续走 -p 模式下的逻辑。
// loop.ts
export async function runAgentPipe(opts: {
prompt: string;
model: string;
maxTurns: number;
}): Promise<void> {
if (!process.env.OPENAI_API_KEY) {
console.error("错误:请设置 OPENAI_API_KEY");
process.exit(1);
}
try {
const { finalAssistantText } = await runAgentConversation(
[{ role: "user", content: opts.prompt }],
{ model: opts.model, maxTurns: opts.maxTurns }
);
// 打印结果
console.log(finalAssistantText);
} catch (e) {
const m = e instanceof Error ? e.message : String(e);
console.error(m);
process.exit(1);
}
}完成上面的代码, 你可以尝试使用 bun run src/index.ts -p “xxx” 命令来测试一下, 看看是否能够正常工作。
下面我们来看 repl-app.tsx 文件, 如何改写, 让它能够使用 runAgentConversation 函数。
三、repl-app.tsx 改写:对话时使用 runAgentConversation 函数
整个 repl-app.tsx 的代码逻辑是: CLI 入口调用 runRepl 函数 -> 调用 REPLApp 组件 -> 提交对话时调用 submit 函数。
这里我们首先改写 runRepl 函数 和 REPLApp 组件以支持传入 maxTurns 参数。
// repl-app.tsx
// 改写 runRepl 函数, 支持传入 `maxTurns` 参数
export async function runRepl(opts: {
model: string;
maxTurns: number;
}): Promise<void> {
if (!process.env.OPENAI_API_KEY) {
console.error("错误:请设置 OPENAI_API_KEY");
process.exit(1);
}
const { render } = await import("ink");
const app = render(
// 传入 `maxTurns` 参数
<REPLApp model={opts.model} maxTurns={opts.maxTurns} />
);
await app.waitUntilExit();
}然后改写 REPLApp 组件, 支持传入 maxTurns 参数。
// repl-app.tsx
export function REPLApp({
model,
maxTurns,
}: {
model: string;
maxTurns: number;
}) {
// ...
}提交对话时在 submit 函数中调用 streamChatCompletion 函数, 现在需要改写, 让它能够使用 runAgentConversation 函数来循环调用模型。
下面是 submit 函数的实现, 代码比较长, 但是相对于之前章节实现的代码改动不大, 重点关注 try {} catch {} 部分的代码改动
// repl-app.tsx
// REPLApp 组件内部
const submit = async (line: string) => {
const trimmed = line.trim();
if (!trimmed || busy) {
return;
}
// 如果输入为斜杠命令, 则执行斜杠命令
if (trimmed.startsWith("/")) {
runSlash(trimmed);
return;
}
const userRow: ChatRow = { id: uid(), role: "user", content: trimmed };
const botId = uid();
const userMsg: ChatCompletionMessageParam = {
role: "user",
content: trimmed,
};
const nextMessages: ChatCompletionMessageParam[] = [...history, userMsg];
setRows((r) => [
...r,
userRow,
{ id: botId, role: "assistant", content: "", streaming: true },
]);
setBusy(true);
const t0 = performance.now();
try {
// 这里也删掉了 流式拼接的逻辑,
// 因为 `runAgentConversation` 函数 还不支持流式输出, 后续的章节会补充。
// 调用 `runAgentConversation` 函数, 循环调用模型
const { messages: after, finalAssistantText } =
await runAgentConversation(nextMessages, {
model,
maxTurns,
// 在进入工具轮时回调给 `REPL` 展示信息
onToolRound: ({ toolNames }) => {
setRows((prev) =>
prev.map((row) =>
// 区分 调用工具, 来更新历史对话
row.id === botId
? {
...row,
content: `正在调用工具:${toolNames.join(", ") || "(未知)"}\n`,
}
: row
)
);
},
});
const sec = ((performance.now() - t0) / 1000).toFixed(1);
const footer = `\n[完成,约 ${roughTokens(finalAssistantText)} tokens,${sec}s]`;
// 更新助手消息行
setRows((prev) =>
prev.map((row) =>
row.id === botId
? {
...row,
content: `${finalAssistantText}${footer}`,
streaming: false,
}
: row
)
);
// 更新历史对话
setHistory(after);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setRows((prev) =>
prev.map((row) =>
row.id === botId
? { ...row, content: `错误:${msg}`, streaming: false }
: row
)
);
} finally {
setBusy(false);
}
};- 上面的代码主要是将
streamChatCompletion函数替换为runAgentConversation函数, 并增加了onToolRound回调函数, 用来在进入工具轮时展示信息。 - 另外 对
history数组的类型也做了调整, 从Message[]类型的数组, 改为ChatCompletionMessageParam[]类型的数组, 这样就可以包含工具调用的消息。 - 删掉了流式拼接的逻辑, 因为
runAgentConversation函数 还不支持流式输出, 后续的章节会补充。
这样下来我们在 REPL 模式下也接入了 Agentic Loop 和 Context Builder 的能力。
接下来我们来看 cli.ts 文件,让它能够支持传入 maxTurns 参数。
// cli.ts
// 定义最大轮次, 默认 16 轮
const DEFAULT_MAX_TURNS = 16;
// program.action 函数内部
// 无参数且非管道:进入交互 REPL
if (!(prompt || opts.pipe)) {
// 动态导入 `runRepl` 避免入口阻塞
const { runRepl } = await import("./ui/repl-app.js");
await runRepl({
model: opts.model,
maxTurns: Number(opts.maxTurns ?? DEFAULT_MAX_TURNS),
});
return;
}四、总结
我们的项目在不同的模式下调用关系是这样的:
CLI
├─ 无参数 → runRepl → REPLApp.submit → runAgentConversation → callModel(buildSystemPrompt) + tools
└─ `-p` → runAgentPipe → runAgentConversation(同一套)可以看到:Ink 只管渲染 UI,buildSystemPrompt 仍在 callModel 这条线上,整条链的核心就是 runAgentConversation。
最终我们在 REPL 模式下的运行效果是这样的。

唯一有点遗憾的是, 我们删掉了流式拼接的逻辑, 因为 runAgentConversation 函数 还不支持流式输出, 下一节我们会补充这个功能。
喜欢我的文章,欢迎关注我的公众号:「闲不住的李先森」,我会定期分享 AI 编程相关的知识和经验。

