状态机与思考循环
——CogitoAgent开发实战(一)
📖 本文是专栏《让大模型真正“活”在你电脑里——CogitoAgent开发实战》的第一篇。我们将一起思考一个问题:如何让一个AI程序既能在后台“自己琢磨事儿”,又能随时响应你的指令?这就是状态机和思考循环要解决的核心问题。
📌 从一个生活场景开始
想象你有一个非常能干的私人助理。
平常的时候,他会自己在办公室里转悠——整理文件、翻阅资料、熟悉你的工作内容。你不需要时刻盯着他,他自己知道该做什么。
当你需要他时,你只需要喊一声,他就会立刻停下来,走到你面前,听你吩咐。你说完,他又回到自己的节奏里,继续忙活。
这就是 CogitoAgent 的工作模式。
技术翻译:
- “自己转悠” =
THINKING状态(AI主动探索) - “喊一声” = 按 Enter 打断
- “听你吩咐” =
AWAITING_INPUT状态(等待用户输入)
这个机制看似简单,但实现起来有几个棘手的难题。
一、核心难题:AI的“自言自语”和“听你说话”不能打架
1.1 如果我们不做状态管理,会发生什么?
假设我们用一个最简单的while循环:
// ❌ 错误示范while(true){constuserInput=getUserInput();// 等着用户打字constresponse=callAI(userInput);console.log(response);}这里有个致命问题:getUserInput()会卡住整个程序。AI 只能在你输入之后才有反应,永远不可能主动做任何事情。
反过来,如果让 AI 持续运行:
// ❌ 另一个错误示范while(true){constresponse=callAI();// AI自己思考console.log(response);// 用户想说话?程序根本不给你机会}这样用户永远无法插嘴。
问题的本质:我们需要一个程序,能同时做两件事——既能自己运转,又能随时响应外部输入。但在传统的同步编程里,程序一次只能做一件事。
1.2 Node.js 的解法:异步 + 事件驱动
Node.js 的核心优势是异步非阻塞。你可以这样理解:
- 程序里有一个事件队列,放着各种待处理的事情(定时器到点了、用户按键盘了、网络请求回来了)
- 主线程不断从这个队列里取任务执行
- 如果一个任务需要等待(比如等用户输入),它不会卡住整个程序,而是把自己挂起,让其他任务先执行
CogitoAgent 正是利用了这一点。
两个核心机制:
- 定时器:每 3 秒触发一次“让 AI 思考”的任务
- 事件监听:用户按 Enter 时,触发“处理用户输入”的任务
这两个任务不会同时执行,但它们可以交替执行——就像一个人可以一边吃饭一边看手机,虽然同一时刻只能做一件事,但切换得足够快,感觉就像同时在做。
二、状态机:用一个“模式开关”来管理行为
有了异步机制,我们还需要一个规则来决定:当前应该做什么?
这就是状态机。
2.1 两个状态的定义
constSTATE={THINKING:'THINKING',// 模式A:AI自己思考AWAITING_INPUT:'AWAITING_INPUT'// 模式B:等待用户输入};letstate=STATE.THINKING;// 启动后默认进入思考模式你可以把state理解为一个模式开关:
| 开关位置 | 程序行为 |
|---|---|
THINKING | 定时器触发时,执行thinkCycle()(AI思考一轮) |
AWAITING_INPUT | 定时器触发时,什么都不做(等待用户) |
2.2 为什么需要两个状态?用一个布尔值不行吗?
你可能会想:用一个isThinking布尔值不就够了?
letisThinking=true;// true=思考中,false=等待输入理论上可以,但随着逻辑变复杂,布尔值会带来困扰:
- “思考中”状态下,用户打断后应该进入“等待输入”——布尔值从
true变false,没问题 - “等待输入”状态下,用户发了消息应该恢复思考——布尔值从
false变true,也没问题
那为什么还要用两个具名的状态常量?
原因一:可读性
// 用布尔值if(!isThinking){...}// 用状态常量if(state===STATE.AWAITING_INPUT){...}后者一眼就能看懂是在检查“是否在等待用户输入”,前者需要你记住isThinking === false是什么意思。
原因二:扩展性
如果将来需要第三个状态(比如“暂停”“错误”等),布尔值就彻底不够用了。用状态常量,增加一个值即可。
原因三:防止歧义
布尔值无法表达“为什么会是这个状态”。具名的状态常量自带语义。
2.3 状态的转换规则
状态的转换不是随意的,有明确的规则:
启动 ↓ THINKING ──(用户按Enter)──→ AWAITING_INPUT ↑ │ └──(用户发送消息)────────────┘ THINKING ──(AI输出[WAIT])──→ AWAITING_INPUT什么时候从THINKING变成AWAITING_INPUT?
两种情况:
- 用户主动打断:你按了 Enter
- AI 主动等待: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)阻塞主线程,用户输入就永远得不到处理了。
解法:递归的setTimeout
functionscheduleNext(){setTimeout(()=>{doSomething();scheduleNext();// 做完后,再安排下一次},3000);}这个模式的关键在于:setTimeout只是“安排”一个任务在 3 秒后执行,安排完就立刻返回。主线程可以在这 3 秒里做其他事情(比如响应用户输入)。
3 秒后,doSomething()被执行,执行完又调用scheduleNext(),再次安排下一个 3 秒后的任务。
这就形成了一个永不阻塞、永不停止的循环。
3.2 为什么要先clearTimeout?
lettimer=null;functionscheduleNext(){clearTimeout(timer);// 清除之前的定时器timer=setTimeout(()=>{// ...},3000);}这个细节很重要。考虑一个场景:
- 第 0 秒:
scheduleNext()被调用,安排 3 秒后执行任务 - 第 1 秒:用户按 Enter 打断,我们调用了
scheduleNext()(想重新安排?或者只是重置?) - 如果不先
clearTimeout,第 0 秒安排的那个定时器仍然存在,3 秒后(即第 3 秒)还会触发
这可能导致意料之外的任务执行。clearTimeout保证了:每次安排新任务之前,先把旧任务取消掉。确保只有最后一次安排会生效。
3.3 状态的“守卫”:只在 THINKING 模式下执行
functionscheduleNext(){clearTimeout(timer);timer=setTimeout(()=>{if(state===STATE.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 解决方案:中断标志
letshouldStop=false;// 全局中断标志这是一个共享变量,用户输入处理和思考循环都能访问到。
打断流程:
- 用户按 Enter →
handleUserInput被调用 handleUserInput设置shouldStop = truehandleUserInput清除定时器,防止下次循环启动handleUserInput将状态改为AWAITING_INPUTthinkCycle()在执行过程中,不断检查shouldStop,发现为true就立即退出
4.3 中断检查点
thinkCycle()中,我们在关键位置检查中断标志:
asyncfunctionthinkCycle(){// 检查点1:函数开头(还没开始干活)if(shouldStop)return;forawait(constchunkofstreamChat(messages)){// 检查点2:每收到一个响应块,都检查一次if(shouldStop){shouldStop=false;// 重置标志return;// 立即退出}// 处理chunk...}// 检查点3:重要操作之间也可以加if(shouldStop)return;// 执行工具...}注意检查点2:LLM 的流式响应可能持续很长时间(比如生成几百字的回复)。我们在每个 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]')){wantsToWait=true;}// 在 thinkCycle 末尾决定下一轮状态if(wantsToWait){state=STATE.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,不会留下一个空白的思考区 |
八、把零散的知识串起来
现在我们来看看所有这些机制是如何协同工作的:
核心设计哲学:
- 状态驱动:程序的行为由当前状态决定,而不是散落在各处的条件判断
- 中断优先:用户打断是最高的优先级,任何时候都应该被响应
- 约定优于配置:工具调用用纯文本标记,不需要复杂的 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 👈