从「一问一答」到「一直聊」:给最简 Agent 加上 QueryEngine + REPL
前两篇主循环query()、Grep / Glob / 分段 Read。Agent 会转了也会逛代码库了——但启动方式还是答完就退出。没法说「我叫 Alice」下一句再问「我叫什么」。这篇讲L1 会话层QueryEngine管多轮历史readline REPL 开着一个会话一直聊。query.ts依然一个字不改。缺的那一层会话 vs 循环Harness 可以粗分成四层层问题之前有什么L1 会话谁、何时触发一次用户 turn历史怎么攒❌ 只有单次 CLIL2 循环一轮用户意图里模型↔工具转几圈✅query()L3 工具每个tool_use怎么执行✅ Grep / Glob / Read…L4 基础消息协议、Provider、deps✅关键概念要分清一次query()一次用户提交后的完整 ReAct 循环内部可能多轮 tool一轮用户对话turn用户敲一行字 → 一次runTurn→ 一次query()一个会话session多轮 turn 共用同一份messages[]v0 是「一次 turn 就结束进程」。v1 要的是一个进程里turn 可以连着来上下文不丢。启动路由三种模式bun run dev │ ├─ -p → pipestdin 一条问题答完退出 ├─ -- 问题 → headlessargv 一条问题答完退出 └─ 无问题参数 → REPL 提示符一直聊实现就是一小段路由exportfunctionresolveLaunchMode(argv:string[]):LaunchMode{if(argv.includes(-p))returnpipeif(parseUserPrompt(argv))returnheadlessreturnrepl}兼容性承诺带参数 / pipe 的行为与 v0 完全一致。想交互时就像许多 CLI Agent 一样——直接bun run dev等。bun run dev# 交互 REPLbun run dev:mock# mock REPL需扩展 mock 才有真·多轮智能验收建议真实 Keybun run dev --一句话# 仍是 headlessQueryEngine会话的「记事本」QueryEngine是 L1 的核心职责刻意做小classQueryEngine{getmessages():Message[]async*runTurn(userText:string):AsyncGeneratorQueryYield,Terminalclear():void}runTurn内部就三件事messages.push(userMessage)调用现有的query({ messages, tools, … })把 yield 出来的完整assistant/user含tool_result再合并回messagesasync*runTurn(userText:string){this.#messages.push(createUserMessage(userText))constgenquery({messages:this.#messages,/* … */})while(true){const{value,done}awaitgen.next()if(done)returnvalueif(value.typeassistant||value.typeuser){this.#messages.push(value)}yieldvalue// 继续给 CLI / REPL 流式打印}}注意text_delta只用于展示不进历史——历史里存的是结构化的完整消息和模型协议一致。为什么 headless 可以不经过它单次问答不需要「跨 turn 记忆」。headless 仍直接query({messages:[createUserMessage(prompt)],tools,toolUseContext})REPL 持有一个QueryEngine实例测试也可以直接new QueryEngine不必起 readline。面试一句话L2 的query()只管「这一轮用户意图怎么跑完」L1 的QueryEngine管「很多轮用户意图之间历史怎么长」。REPLreadline不上 Ink完整版 Agent 往往有终端 Ink UI本仓库选择node:readline/promises选择理由readline零 UI 依赖、跨平台、焦点落在 L1→L2 边界不上 Ink未来换成漂亮 TUI 时可以不动QueryEngine会话循环抽成可测的runReplSession喂AsyncIterablestring真正绑终端时再套 readline 我叫 Alice 模型回复… 我叫什么 模型应能引用上一轮 /clear 会话已清空 /exitSlash 命令本地处理不送模型命令行为/exit、/quit正常退出/clearengine.clear()打印「会话已清空」/help打印命令列表未知/foo提示未知不当作用户问题空行跳过不发起query这是会话层的另一半职责把「对产品说话」和「对模型说话」分开。输出复用一处打印两处复用以前流式打印埋在cli.ts里。上了 REPL 后若再抄一份必然漂移。抽出consumeQueryStream(gen):PromiseTerminal约定不变模型文本 →stdout工具状态 / 错误 →stderrprintedTextThisTurn防止text_delta与assistant全文重复打印REPL 每轮await consumeQueryStream(engine.runTurn(line))headlessawait consumeQueryStream(query(…))展示层与会话层再分离一层——同一条打印管道接「单次 query」或「多轮 runTurn」都行。数据流补上 L1用户敲回车 → REPL 读行 → slash本地处理并 continue → QueryEngine.runTurn(text) → push user → query() L2 循环看/停/干/限/记 → yield 事件 合并 messages → consumeQueryStream 打印 → 再显示 前两篇里的五字诀、工具流水线全部嵌在query()里面对 REPL 透明。刻意没做什么没做为什么Ink / 权限弹窗先把会话边界钉死会话落盘/clear足以应付 v1持久化是另题/model等扩展 slash协议先留住/前缀compact / 上下文压缩长对话会胀先靠/clear压缩留给下一刀长会话风险很明确messages会无限长。v1 选择暴露问题用/clear而不是假装已经有 compact。30 秒上手gitclone https://github.com/jimchou-h/react-agent-mini.gitcdreact-agent-mini buninstall# 交互会话需 OPENAI_API_KEY或先设 mock 玩 slashbun run dev# 对照旧的一问一答仍在bun run dev --用 Echo 回复 hello建议自己测这三步第一轮「请记住项目暗号是 pineapple」第二轮「项目暗号是什么」——应带着上一轮上下文答/clear后再问同一句——应不再记得你可以从这里带走什么query()≠ 整个产品它是 L2多轮连续聊要在外面加 L1QueryEngine REPL。会话层最便宜的正确拆法runTurn只多做「攒历史」循环、工具、Provider 全复用。UI 可以换边界别糊readline 今天够用明天 Ink只要还调QueryEngine中间那层就没白写。到这里react-agent-mini 的最小故事拼齐了三块砖篇章补上的能力主循环Agent 会转代码库工具Agent 会逛仓库本篇 REPLAgent 能开着聊下一步可以是权限、compact或把会话层再接到更漂亮的终端 UI——但架子已经在了。仓库与相关文档GitHubhttps://github.com/jimchou-h/react-agent-mini主循环 · 代码库工具架构导读docs/architecture.md欢迎 Star、Issue 和 PR。本文基于 react-agent-mini 变更v1-repl-sessionQueryEngine readline REPL撰写。