状态机与思考循环
------CogitoAgent开发实战(一)
📖 本文是专栏《让大模型真正"活"在你电脑里------CogitoAgent开发实战》的第一篇。我们将一起思考一个问题:如何让一个AI程序既能在后台"自己琢磨事儿",又能随时响应你的指令?这就是状态机和思考循环要解决的核心问题。

📌 从一个生活场景开始
想象你有一个非常能干的私人助理。
平常的时候,他会自己在办公室里转悠------整理文件、翻阅资料、熟悉你的工作内容。你不需要时刻盯着他,他自己知道该做什么。
当你需要他时,你只需要喊一声,他就会立刻停下来,走到你面前,听你吩咐。你说完,他又回到自己的节奏里,继续忙活。
这就是 CogitoAgent 的工作模式。
技术翻译:
- "自己转悠" =
THINKING状态(AI主动探索) - "喊一声" = 按 Enter 打断
- "听你吩咐" =
AWAITING_INPUT状态(等待用户输入)
这个机制看似简单,但实现起来有几个棘手的难题。
一、核心难题:AI的"自言自语"和"听你说话"不能打架
1.1 如果我们不做状态管理,会发生什么?
假设我们用一个最简单的 while 循环:
javascript
// ❌ 错误示范
while(true) {
const userInput = getUserInput(); // 等着用户打字
const response = callAI(userInput);
console.log(response);
}
这里有个致命问题:getUserInput() 会卡住整个程序。AI 只能在你输入之后才有反应,永远不可能主动做任何事情。
反过来,如果让 AI 持续运行:
javascript
// ❌ 另一个错误示范
while(true) {
const response = callAI(); // AI自己思考
console.log(response);
// 用户想说话?程序根本不给你机会
}
这样用户永远无法插嘴。
问题的本质 :我们需要一个程序,能同时做两件事------既能自己运转,又能随时响应外部输入。但在传统的同步编程里,程序一次只能做一件事。
1.2 Node.js 的解法:异步 + 事件驱动
Node.js 的核心优势是异步非阻塞。你可以这样理解:
- 程序里有一个事件队列,放着各种待处理的事情(定时器到点了、用户按键盘了、网络请求回来了)
- 主线程不断从这个队列里取任务执行
- 如果一个任务需要等待(比如等用户输入),它不会卡住整个程序,而是把自己挂起,让其他任务先执行
CogitoAgent 正是利用了这一点。
两个核心机制:
- 定时器:每 3 秒触发一次"让 AI 思考"的任务
- 事件监听:用户按 Enter 时,触发"处理用户输入"的任务
这两个任务不会同时执行 ,但它们可以交替执行------就像一个人可以一边吃饭一边看手机,虽然同一时刻只能做一件事,但切换得足够快,感觉就像同时在做。

二、状态机:用一个"模式开关"来管理行为
有了异步机制,我们还需要一个规则来决定:当前应该做什么?
这就是状态机。

2.1 两个状态的定义
javascript
const STATE = {
THINKING: 'THINKING', // 模式A:AI自己思考
AWAITING_INPUT: 'AWAITING_INPUT' // 模式B:等待用户输入
};
let state = STATE.THINKING; // 启动后默认进入思考模式
你可以把 state 理解为一个模式开关:
| 开关位置 | 程序行为 |
|---|---|
THINKING |
定时器触发时,执行 thinkCycle()(AI思考一轮) |
AWAITING_INPUT |
定时器触发时,什么都不做(等待用户) |
2.2 为什么需要两个状态?用一个布尔值不行吗?
你可能会想:用一个 isThinking 布尔值不就够了?
javascript
let isThinking = true; // true=思考中,false=等待输入
理论上可以,但随着逻辑变复杂,布尔值会带来困扰:
- "思考中"状态下,用户打断后应该进入"等待输入"------布尔值从
true变false,没问题 - "等待输入"状态下,用户发了消息应该恢复思考------布尔值从
false变true,也没问题
那为什么还要用两个具名的状态常量?
原因一:可读性
javascript
// 用布尔值
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),怎么实现循环?
在普通编程里,想重复做一件事,我们会写:
javascript
while(true) {
doSomething();
sleep(3000); // 等3秒
}
但在 Node.js 里,没有 sleep() 函数(实际上有一个 setTimeout,但它不会像 sleep 那样阻塞程序)。更重要的是,如果我们用 while(true) 阻塞主线程,用户输入就永远得不到处理了。
解法:递归的 setTimeout
javascript
function scheduleNext() {
setTimeout(() => {
doSomething();
scheduleNext(); // 做完后,再安排下一次
}, 3000);
}
这个模式的关键在于:setTimeout 只是"安排"一个任务在 3 秒后执行,安排完就立刻返回。主线程可以在这 3 秒里做其他事情(比如响应用户输入)。
3 秒后,doSomething() 被执行,执行完又调用 scheduleNext(),再次安排下一个 3 秒后的任务。
这就形成了一个永不阻塞、永不停止的循环。
3.2 为什么要先 clearTimeout?
javascript
let timer = null;
function scheduleNext() {
clearTimeout(timer); // 清除之前的定时器
timer = setTimeout(() => {
// ...
}, 3000);
}
这个细节很重要。考虑一个场景:
- 第 0 秒:
scheduleNext()被调用,安排 3 秒后执行任务 - 第 1 秒:用户按 Enter 打断,我们调用了
scheduleNext()(想重新安排?或者只是重置?) - 如果不先
clearTimeout,第 0 秒安排的那个定时器仍然存在,3 秒后(即第 3 秒)还会触发
这可能导致意料之外的任务执行 。clearTimeout 保证了:每次安排新任务之前,先把旧任务取消掉。确保只有最后一次安排会生效。
3.3 状态的"守卫":只在 THINKING 模式下执行
javascript
function scheduleNext() {
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 解决方案:中断标志
javascript
let shouldStop = false; // 全局中断标志
这是一个共享变量,用户输入处理和思考循环都能访问到。
打断流程:
- 用户按 Enter →
handleUserInput被调用 handleUserInput设置shouldStop = truehandleUserInput清除定时器,防止下次循环启动handleUserInput将状态改为AWAITING_INPUTthinkCycle()在执行过程中,不断检查shouldStop,发现为true就立即退出
4.3 中断检查点
thinkCycle() 中,我们在关键位置检查中断标志:
javascript
async function thinkCycle() {
// 检查点1:函数开头(还没开始干活)
if (shouldStop) return;
for await (const chunk of streamChat(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 实现方式
javascript
// 检测回复中是否包含 [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 解析过程
javascript
// 正则表达式拆解
/\[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 才能基于结果做出下一步决策。
javascript
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,不会留下一个空白的思考区 |
八、把零散的知识串起来
现在我们来看看所有这些机制是如何协同工作的:
Tool LLM 状态机 定时器调度 User Tool LLM 状态机 定时器调度 User #mermaid-svg-ZoDEd1WszTn6qPzR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZoDEd1WszTn6qPzR .error-icon{fill:#552222;}#mermaid-svg-ZoDEd1WszTn6qPzR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZoDEd1WszTn6qPzR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZoDEd1WszTn6qPzR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZoDEd1WszTn6qPzR .marker.cross{stroke:#333333;}#mermaid-svg-ZoDEd1WszTn6qPzR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZoDEd1WszTn6qPzR p{margin:0;}#mermaid-svg-ZoDEd1WszTn6qPzR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZoDEd1WszTn6qPzR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZoDEd1WszTn6qPzR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZoDEd1WszTn6qPzR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZoDEd1WszTn6qPzR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZoDEd1WszTn6qPzR .sequenceNumber{fill:white;}#mermaid-svg-ZoDEd1WszTn6qPzR #sequencenumber{fill:#333;}#mermaid-svg-ZoDEd1WszTn6qPzR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZoDEd1WszTn6qPzR .messageText{fill:#333;stroke:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZoDEd1WszTn6qPzR .labelText,#mermaid-svg-ZoDEd1WszTn6qPzR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .loopText,#mermaid-svg-ZoDEd1WszTn6qPzR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZoDEd1WszTn6qPzR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZoDEd1WszTn6qPzR .noteText,#mermaid-svg-ZoDEd1WszTn6qPzR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZoDEd1WszTn6qPzR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZoDEd1WszTn6qPzR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZoDEd1WszTn6qPzR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZoDEd1WszTn6qPzR .actorPopupMenu{position:absolute;}#mermaid-svg-ZoDEd1WszTn6qPzR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZoDEd1WszTn6qPzR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZoDEd1WszTn6qPzR .actor-man circle,#mermaid-svg-ZoDEd1WszTn6qPzR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZoDEd1WszTn6qPzR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每3秒触发 检查状态 THINKING 调用API 按Enter shouldStop=true 清除定时器 切换到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 支持一下开源项目!