状态机与思考循环——CogitoAgent开发实战一 本文是专栏《让大模型真正“活”在你电脑里——CogitoAgent开发实战》的第一篇。我们将一起思考一个问题如何让一个AI程序既能在后台“自己琢磨事儿”又能随时响应你的指令这就是状态机和思考循环要解决的核心问题。 从一个生活场景开始想象你有一个非常能干的私人助理。平常的时候他会自己在办公室里转悠——整理文件、翻阅资料、熟悉你的工作内容。你不需要时刻盯着他他自己知道该做什么。当你需要他时你只需要喊一声他就会立刻停下来走到你面前听你吩咐。你说完他又回到自己的节奏里继续忙活。这就是 CogitoAgent 的工作模式。技术翻译“自己转悠” THINKING状态AI主动探索“喊一声” 按 Enter 打断“听你吩咐” AWAITING_INPUT状态等待用户输入这个机制看似简单但实现起来有几个棘手的难题。一、核心难题AI的“自言自语”和“听你说话”不能打架1.1 如果我们不做状态管理会发生什么假设我们用一个最简单的while循环// ❌ 错误示范while(true){constuserInputgetUserInput();// 等着用户打字constresponsecallAI(userInput);console.log(response);}这里有个致命问题getUserInput()会卡住整个程序。AI 只能在你输入之后才有反应永远不可能主动做任何事情。反过来如果让 AI 持续运行// ❌ 另一个错误示范while(true){constresponsecallAI();// AI自己思考console.log(response);// 用户想说话程序根本不给你机会}这样用户永远无法插嘴。问题的本质我们需要一个程序能同时做两件事——既能自己运转又能随时响应外部输入。但在传统的同步编程里程序一次只能做一件事。1.2 Node.js 的解法异步 事件驱动Node.js 的核心优势是异步非阻塞。你可以这样理解程序里有一个事件队列放着各种待处理的事情定时器到点了、用户按键盘了、网络请求回来了主线程不断从这个队列里取任务执行如果一个任务需要等待比如等用户输入它不会卡住整个程序而是把自己挂起让其他任务先执行CogitoAgent 正是利用了这一点。两个核心机制定时器每 3 秒触发一次“让 AI 思考”的任务事件监听用户按 Enter 时触发“处理用户输入”的任务这两个任务不会同时执行但它们可以交替执行——就像一个人可以一边吃饭一边看手机虽然同一时刻只能做一件事但切换得足够快感觉就像同时在做。二、状态机用一个“模式开关”来管理行为有了异步机制我们还需要一个规则来决定当前应该做什么这就是状态机。2.1 两个状态的定义constSTATE{THINKING:THINKING,// 模式AAI自己思考AWAITING_INPUT:AWAITING_INPUT// 模式B等待用户输入};letstateSTATE.THINKING;// 启动后默认进入思考模式你可以把state理解为一个模式开关开关位置程序行为THINKING定时器触发时执行thinkCycle()AI思考一轮AWAITING_INPUT定时器触发时什么都不做等待用户2.2 为什么需要两个状态用一个布尔值不行吗你可能会想用一个isThinking布尔值不就够了letisThinkingtrue;// true思考中false等待输入理论上可以但随着逻辑变复杂布尔值会带来困扰“思考中”状态下用户打断后应该进入“等待输入”——布尔值从true变false没问题“等待输入”状态下用户发了消息应该恢复思考——布尔值从false变true也没问题那为什么还要用两个具名的状态常量原因一可读性// 用布尔值if(!isThinking){...}// 用状态常量if(stateSTATE.AWAITING_INPUT){...}后者一眼就能看懂是在检查“是否在等待用户输入”前者需要你记住isThinking false是什么意思。原因二扩展性如果将来需要第三个状态比如“暂停”“错误”等布尔值就彻底不够用了。用状态常量增加一个值即可。原因三防止歧义布尔值无法表达“为什么会是这个状态”。具名的状态常量自带语义。2.3 状态的转换规则状态的转换不是随意的有明确的规则启动 ↓ THINKING ──(用户按Enter)──→ AWAITING_INPUT ↑ │ └──(用户发送消息)────────────┘ THINKING ──(AI输出[WAIT])──→ AWAITING_INPUT什么时候从THINKING变成AWAITING_INPUT两种情况用户主动打断你按了 EnterAI 主动等待AI 在回复中写了[WAIT]表示“我说完了等你回话”什么时候从AWAITING_INPUT变回THINKING用户发送了消息可以是具体内容也可以直接按 Enter 发空消息表示“没事你继续想”三、思考循环如何让AI“每3秒想一次”3.1 问题不用while(true)怎么实现循环在普通编程里想重复做一件事我们会写while(true){doSomething();sleep(3000);// 等3秒}但在 Node.js 里没有sleep()函数实际上有一个setTimeout但它不会像sleep那样阻塞程序。更重要的是如果我们用while(true)阻塞主线程用户输入就永远得不到处理了。解法递归的setTimeoutfunctionscheduleNext(){setTimeout((){doSomething();scheduleNext();// 做完后再安排下一次},3000);}这个模式的关键在于setTimeout只是“安排”一个任务在 3 秒后执行安排完就立刻返回。主线程可以在这 3 秒里做其他事情比如响应用户输入。3 秒后doSomething()被执行执行完又调用scheduleNext()再次安排下一个 3 秒后的任务。这就形成了一个永不阻塞、永不停止的循环。3.2 为什么要先clearTimeoutlettimernull;functionscheduleNext(){clearTimeout(timer);// 清除之前的定时器timersetTimeout((){// ...},3000);}这个细节很重要。考虑一个场景第 0 秒scheduleNext()被调用安排 3 秒后执行任务第 1 秒用户按 Enter 打断我们调用了scheduleNext()想重新安排或者只是重置如果不先clearTimeout第 0 秒安排的那个定时器仍然存在3 秒后即第 3 秒还会触发这可能导致意料之外的任务执行。clearTimeout保证了每次安排新任务之前先把旧任务取消掉。确保只有最后一次安排会生效。3.3 状态的“守卫”只在 THINKING 模式下执行functionscheduleNext(){clearTimeout(timer);timersetTimeout((){if(stateSTATE.THINKING){// 守卫thinkCycle();scheduleNext();}},3000);}这个if检查至关重要。当程序处于AWAITING_INPUT状态时我们不希望 AI 继续思考。但这个定时器是已经安排好的到点就会触发。守卫的作用就是到点了先看看当前是什么模式。如果是等待输入模式就直接跳过不执行思考也不继续安排下一次循环。这也意味着当状态从AWAITING_INPUT切回THINKING时需要主动调用scheduleNext()来恢复循环。四、用户打断如何让AI“立刻闭嘴”4.1 打断的挑战用户按 Enter 时AI 可能正在做两件事之一正在思考thinkCycle()还没开始或者还没执行完正在等待状态是AWAITING_INPUT啥也没干第二种情况很简单——本来就在等你不需要打断。第一种情况复杂thinkCycle()是一个异步函数里面可能正在等待 LLM 的流式响应一个可能持续几秒到十几秒的网络请求执行文件操作读取大文件可能耗时我们希望无论 AI 当前在做什么用户按 Enter 后它应该立即停止当前活动进入等待输入模式。4.2 解决方案中断标志letshouldStopfalse;// 全局中断标志这是一个共享变量用户输入处理和思考循环都能访问到。打断流程用户按 Enter →handleUserInput被调用handleUserInput设置shouldStop truehandleUserInput清除定时器防止下次循环启动handleUserInput将状态改为AWAITING_INPUTthinkCycle()在执行过程中不断检查shouldStop发现为true就立即退出4.3 中断检查点thinkCycle()中我们在关键位置检查中断标志asyncfunctionthinkCycle(){// 检查点1函数开头还没开始干活if(shouldStop)return;forawait(constchunkofstreamChat(messages)){// 检查点2每收到一个响应块都检查一次if(shouldStop){shouldStopfalse;// 重置标志return;// 立即退出}// 处理chunk...}// 检查点3重要操作之间也可以加if(shouldStop)return;// 执行工具...}注意检查点2LLM 的流式响应可能持续很长时间比如生成几百字的回复。我们在每个 chunk 到达时都检查中断标志这样用户打断时最多浪费一个 chunk 的处理而不是等整个响应完成。4.4 为什么需要clearTimeout配合设置shouldStop true只能让正在执行的thinkCycle()退出。但定时器可能已经安排了下一次thinkCycle()。假设第 0 秒scheduleNext()安排了 3 秒后的任务第 1 秒用户打断shouldStop true当前thinkCycle()退出第 3 秒定时器触发启动新一轮thinkCycle()这会导致打断后 AI 又自己跑起来了不是用户想要的。所以打断时必须同时做三件事设置shouldStop true让当前执行退出clearTimeout(thinkingTimer)取消已安排的下一次修改状态为AWAITING_INPUT让未来的定时器检查不通过五、AI主动等待[WAIT] 标签的设计5.1 为什么需要AI主动等待人类对话有个基本规则轮流说话。目前的机制中AI 思考完一轮如果没有任何中断会继续下一轮思考。这意味着 AI 会不停地输出用户永远没机会插嘴。[WAIT] 标签就是为了解决这个问题——让 AI 自己决定“该你说了”。5.2 使用场景AI 什么时候应该主动等待征求同意“我发现你的 Downloads 文件夹很乱要帮你整理一下吗[WAIT]”提问澄清“你说的‘那个文件’指的是哪个[WAIT]”分享发现后等待反馈“我找到了一个 3 年前的备忘录好像很有意思你想看看吗[WAIT]”5.3 实现方式// 检测回复中是否包含 [WAIT]if(fullResponse.includes([WAIT])){wantsToWaittrue;}// 在 thinkCycle 末尾决定下一轮状态if(wantsToWait){stateSTATE.AWAITING_INPUT;println([等待] 我先不说了等你说,gray);}设计细节[WAIT]放在回复的结尾语义上是“我说完了该你了”它只在当前轮次生效不影响下一轮检测只是简单的字符串includes不需要复杂解析5.4 让AI学会使用 [WAIT]光有代码实现不够AI 得知道什么时候该用。我们在系统提示词里加了说明## 探索节奏 当你 - 想分享一个发现、想法或感受 - 想问用户问题 - 想和用户互动 就在你的发言结尾加上 [WAIT]这会让我停下来等你回复。 ## 示例 我觉得这个文件夹很有意思你想让我继续探索这里吗[WAIT]这样 LLM 就会在合适的时机主动输出[WAIT]。六、工具调用让AI“动手”做事6.1 问题AI 只能输出文字怎么让它执行操作LLM 的本质是文本生成器。给它一段 prompt它吐出一段文字。要让 AI “执行操作”我们需要一个约定AI 输出特定的文字格式程序识别这个格式后去执行对应的操作。这就是工具调用协议。6.2 协议设计CogitoAgent 的协议非常简单[TOOL] 工具名称(参数1, 参数2) [/TOOL]例如[TOOL] ls(src) [/TOOL]→ 列出 src 目录[TOOL] read(README.md) [/TOOL]→ 读取 README.md[TOOL] search(人工智能) [/TOOL]→ 联网搜索为什么这么设计设计要求协议如何满足容易被 LLM 学会格式简单类似函数调用LLM 训练数据中常见容易被程序解析正则表达式轻松提取工具名和参数可读性好人类看一眼也能理解不需要特殊 API任何 LLM 都能输出这种纯文本6.3 解析过程// 正则表达式拆解/\[TOOL\]\s*(\w)\s*\(([^)]*)\)\s*\[\/TOOL\]/│ │ │ │ │ │ │ └── 结束标记 │ │ └── 参数部分括号内 │ └── 工具名字母数字 └── 开始标记为什么支持多个工具调用AI 有时需要连续做几件事。比如先ls看看有什么再read读其中一个文件。如果一次响应只支持一个工具调用AI 就需要输出一次、等程序执行完、再输出第二次。这样效率很低。支持多个调用后AI 可以一次输出[TOOL] ls(src) [/TOOL] [TOOL] read(src/index.js) [/TOOL]程序会依次执行并把所有结果收集起来。6.4 执行与反馈执行完工具后程序需要把结果告诉 AI这样 AI 才能基于结果做出下一步决策。addAssistantMessage(fullResponse\n\n[工具结果]:${JSON.stringify(result.data)});添加到历史的消息是这样的[TOOL] ls(src) [/TOOL] [工具结果]: {success:true,data:[agent/,api/,config.js]}AI 看到这段历史就知道工具执行成功了并且看到了结果内容。注意我们同时保留了 AI 的原始输出包含[TOOL]和工具结果。这样 AI 能理解“我上次说要调用 ls结果是 XXX”。七、流式输出的分区展示7.1 问题LLM 的响应是“边想边说”调用 LLM 时它不是一次性返回完整回复而是一个 token 一个 token 地“流”回来。这带来一个 UI 问题如何区分思考过程和正式回复CogitoAgent 利用了一些模型如 DeepSeek提供的reasoning_content字段。这个字段专门存放模型的“内部思考”与正式回复content分开传输。7.2 分区策略我们定义了一个简单的“标签状态机”初始状态 │ ▼ 收到 reasoning ──→ 打印 ┌─ 思考过程灰色然后打印内容 │ ▼ 收到 content ──→ 如果思考区开着先打印 └── 关闭思考区 再打印分隔线和 ▼ 回复内容醒目颜色 然后打印正文 │ ▼ 遇到 [TOOL] ──→ 打印灰色小框缩进展示7.3 为什么这样设计设计决策原因思考过程用灰色用户知道 AI 在想但不干扰阅读正文正文用醒目分隔线明确告诉用户“AI 开始正式回答了”工具调用用小框缩进工具是中间过程不是最终答案不应该喧宾夺主思考区自动关闭如果 AI 没有reasoning直接输出content不会留下一个空白的思考区八、把零散的知识串起来现在我们来看看所有这些机制是如何协同工作的ToolLLM状态机定时器调度UserToolLLM状态机定时器调度User每3秒触发检查状态THINKING调用API按EntershouldStoptrue清除定时器切换到AWAITING_INPUT中断信号停止生成发送消息切换回THINKING恢复定时器继续思考核心设计哲学状态驱动程序的行为由当前状态决定而不是散落在各处的条件判断中断优先用户打断是最高的优先级任何时候都应该被响应约定优于配置工具调用用纯文本标记不需要复杂的 JSON schema透明反馈AI 的思考、工具执行、最终回答用户都能看到九、思考题学完本章你可以思考以下问题如果 LLM 的流式响应非常慢比如 30 秒用户打断后我们应该立即切断网络连接吗为什么[WAIT]用字符串包含检测如果 AI 在回复中正常讨论[WAIT]这个标签本身比如“你可以用 [WAIT] 来让我暂停”会发生什么如何解决当前的调度间隔是固定的 3 秒。如果某次thinkCycle()执行了 5 秒下一次会在 5 秒后立刻执行因为定时器是在任务完成后才安排下一次。这是期望的行为吗为什么十、小结本章讲解了 CogitoAgent 的心脏——状态机与思考循环概念解决的问题实现方式双状态AI 主动探索 vs 等待用户THINKING/AWAITING_INPUT思考循环如何让程序持续运行不阻塞递归setTimeout用户打断如何让 AI 立刻停下来中断标志 clearTimeout[WAIT]让 AI 主动让出话语权检测标签 状态切换工具调用让 AI 执行具体操作[TOOL]标记协议流式分区区分思考/回复/工具标签状态机 ANSI 颜色下一篇预告工具系统的设计与实现我们将深入tools/目录看看文件工具如何安全地读取、写入、复制文件联网工具如何封装搜索和网页抓取系统工具如何在 Windows 上管理进程如何用 4 步添加一个自定义工具如果这篇文章对你有帮助欢迎 ⭐Star 支持一下开源项目 https://gitee.com/cnt-code/cogito-agent