news 2026/6/9 10:35:15

状态机与思考循环 ——CogitoAgent开发实战(一)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
状态机与思考循环 ——CogitoAgent开发实战(一)

状态机与思考循环

——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 正是利用了这一点。

两个核心机制

  1. 定时器:每 3 秒触发一次“让 AI 思考”的任务
  2. 事件监听:用户按 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=等待输入

理论上可以,但随着逻辑变复杂,布尔值会带来困扰:

  • “思考中”状态下,用户打断后应该进入“等待输入”——布尔值从truefalse,没问题
  • “等待输入”状态下,用户发了消息应该恢复思考——布尔值从falsetrue,也没问题

那为什么还要用两个具名的状态常量?

原因一:可读性

// 用布尔值if(!isThinking){...}// 用状态常量if(state===STATE.AWAITING_INPUT){...}

后者一眼就能看懂是在检查“是否在等待用户输入”,前者需要你记住isThinking === false是什么意思。

原因二:扩展性

如果将来需要第三个状态(比如“暂停”“错误”等),布尔值就彻底不够用了。用状态常量,增加一个值即可。

原因三:防止歧义

布尔值无法表达“为什么会是这个状态”。具名的状态常量自带语义。

2.3 状态的转换规则

状态的转换不是随意的,有明确的规则:

启动 ↓ THINKING ──(用户按Enter)──→ AWAITING_INPUT ↑ │ └──(用户发送消息)────────────┘ THINKING ──(AI输出[WAIT])──→ AWAITING_INPUT

什么时候从THINKING变成AWAITING_INPUT

两种情况:

  1. 用户主动打断:你按了 Enter
  2. 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);}

这个细节很重要。考虑一个场景:

  1. 第 0 秒:scheduleNext()被调用,安排 3 秒后执行任务
  2. 第 1 秒:用户按 Enter 打断,我们调用了scheduleNext()(想重新安排?或者只是重置?)
  3. 如果不先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;// 全局中断标志

这是一个共享变量,用户输入处理和思考循环都能访问到。

打断流程

  1. 用户按 Enter →handleUserInput被调用
  2. handleUserInput设置shouldStop = true
  3. handleUserInput清除定时器,防止下次循环启动
  4. handleUserInput将状态改为AWAITING_INPUT
  5. thinkCycle()在执行过程中,不断检查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 又自己跑起来了,不是用户想要的。

所以打断时必须同时做三件事

  1. 设置shouldStop = true(让当前执行退出)
  2. clearTimeout(thinkingTimer)(取消已安排的下一次)
  3. 修改状态为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,不会留下一个空白的思考区

八、把零散的知识串起来

现在我们来看看所有这些机制是如何协同工作的:

ToolLLM状态机定时器调度UserToolLLM状态机定时器调度User每3秒触发检查状态THINKING调用API按EntershouldStop=true清除定时器切换到AWAITING_INPUT中断信号停止生成发送消息切换回THINKING恢复定时器继续思考

核心设计哲学

  1. 状态驱动:程序的行为由当前状态决定,而不是散落在各处的条件判断
  2. 中断优先:用户打断是最高的优先级,任何时候都应该被响应
  3. 约定优于配置:工具调用用纯文本标记,不需要复杂的 JSON schema
  4. 透明反馈:AI 的思考、工具执行、最终回答,用户都能看到

九、思考题

学完本章,你可以思考以下问题:

  1. 如果 LLM 的流式响应非常慢(比如 30 秒),用户打断后,我们应该立即切断网络连接吗?为什么?

  2. [WAIT]用字符串包含检测,如果 AI 在回复中正常讨论[WAIT]这个标签本身(比如“你可以用 [WAIT] 来让我暂停”),会发生什么?如何解决?

  3. 当前的调度间隔是固定的 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 👈

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/9 10:32:48

工具系统:让AI真正“动手”做事 ——CogitoAgent开发实战(二)

工具系统:让AI真正“动手”做事 ——CogitoAgent开发实战(二) 📖 本文是专栏的第二篇。上一篇我们让AI学会了“思考”——它有了状态机,能持续运转,能被打断,能主动等待。但一个只会“想”不会…

作者头像 李华
网站建设 2026/6/9 10:31:08

Qt写的本地网络调试小工具,TCP/UDP双向收发全支持,开箱即用

本文还有配套的精品资源,点击获取 简介:一款轻量级Qt网络调试工具,内置TCP客户端、TCP服务端、UDP客户端和UDP服务端四个独立模块,支持多实例并行运行,适合局域网设备联调、协议数据包收发验证和嵌入式通信测试。所…

作者头像 李华