1. 为什么我要自己搭一个AI助手,而不是直接用现成产品
市面上现成的AI对话产品已经多到用不过来,我为什么还要花时间自己搭一套?这个问题我被问过不下十次。最直接的原因是:现成产品能做的事,和我想让它做的事,中间隔着一道很难绕过去的墙。
现成产品的交互模式基本固定死了——你发一条消息,它回一条消息,对话结束,上下文清空。但真实的工作场景不是这样的。我经常需要让助手帮我盯一个持续几天的任务,比如每天定时抓取某个数据源的变化、整理成固定格式、攒够一周再统一输出。这种"持久任务"的需求,现成产品要么不支持,要么需要付费订阅高级功能,而且数据还得放在别人的服务器上。
另一个绕不开的点是沙箱。我希望助手能真正执行代码、跑脚本、处理文件,而不是只给我一段"你可以这样写"的代码让我自己复制粘贴去跑。但让AI直接在我本机执行任意代码,风险太大了——万一它写了个删库的脚本呢?所以我需要一个隔离的执行环境,代码在里面跑,跑完把结果拿出来,本机毫发无损。
还有群聊。这个需求听起来有点怪,但实际很有用。我经常需要多个不同角色的AI同时参与一个讨论——一个负责提方案,一个负责挑毛病,一个负责查资料。如果只能一对一对话,我就得自己当传话筒,来回切换,效率极低。让它们在一个群聊房间里自己聊,我只看结论,这才是正确的打开方式。
所以这篇文章要讲的,就是怎么从零把这三件事落地:持久任务调度、代码沙箱执行、多角色群聊协作。我会把每个模块的设计思路、踩过的坑、以及可以直接抄的配置都写清楚。适合有一定编程基础、想自己掌控AI助手行为的开发者,也适合只是想了解这套架构怎么运转的技术爱好者。
2. 整体架构:三个模块怎么拼在一起
2.1 核心组件拆解
整套系统我拆成了四个部分,每个部分职责单一,通过消息队列和数据库串起来。
| 组件 | 职责 | 技术选型建议 |
|---|---|---|
| 调度器 | 管理持久任务的触发、重试、状态追踪 | 轻量级任务队列 + 数据库 |
| 沙箱执行器 | 在隔离环境中运行代码,返回结果 | 容器化隔离方案 |
| 群聊协调器 | 管理多角色对话的轮次、上下文、终止条件 | 状态机 + 消息总线 |
| 记忆存储 | 保存长期上下文、任务历史、角色设定 | 向量库 + 关系型数据库 |
调度器是整个系统的心跳。它不负责具体干活,只负责"什么时候该让谁干活"。我一开始想用现成的定时任务框架,后来发现太重了,很多功能用不上,反而增加了调试成本。最后自己写了一个基于时间轮的小调度器,核心逻辑不到两百行,够用且可控。
沙箱执行器是最需要小心处理的部分。我的原则是:默认拒绝一切,只开放明确需要的权限。代码在容器里跑,网络默认关闭,文件系统只挂载一个临时目录,跑完就销毁。这样即使AI写出了恶意代码,影响范围也被限制在容器内部。
群聊协调器解决的是"多个AI怎么有序对话"的问题。如果不管控,两个AI很容易陷入互相客套的死循环——"你说得对""谢谢你的补充""不客气,你的观点也很好"。所以必须有一个明确的轮次控制和终止条件。
2.2 数据流走向
一次完整的任务执行,数据是这样流动的:
- 调度器触发任务,把任务描述写入消息队列
- 群聊协调器从队列取任务,根据任务类型决定拉哪些角色入场
- 各角色依次发言,发言内容经过记忆存储做上下文检索
- 如果某个角色的发言需要执行代码,调用沙箱执行器
- 沙箱返回结果,注入回群聊上下文
- 协调器判断是否达到终止条件,达到则汇总输出,未达到则继续下一轮
这个流程里最关键的设计是所有状态都持久化。调度器的任务状态、群聊的对话历史、沙箱的执行记录,全部落库。这样即使服务重启,任务也能从断点恢复,不会丢。我吃过这个亏——早期版本状态全放内存,服务器一重启,跑了三天的任务全没了,那种感觉相当酸爽。
2.3 为什么不用现成的Agent框架
现在市面上有不少Agent开发框架,功能看起来很全。我试过几个,最后都放弃了。原因有三:
第一,抽象层太厚。框架帮你封装了很多东西,但一旦出问题,你得先理解框架的抽象才能调试,排查链路太长。自己写的代码,每一行都知道在干什么。
第二,定制成本高。我的需求比较特殊,比如群聊的轮次控制逻辑、沙箱的权限粒度,现成框架要么不支持,要么得改源码。改源码的维护成本,还不如自己写。
第三,依赖太重。有些框架拉下来几十个依赖包,版本冲突能折腾半天。自己搭的话,核心依赖控制在五个以内,清爽很多。
当然,如果你只是想快速验证一个想法,用现成框架没问题。但如果你打算长期维护、深度定制,自己搭更划算。
3. 持久任务:让助手记住"三天后还要做这件事"
3.1 任务状态机的设计
持久任务的核心不是"定时",而是"状态管理"。一个任务从创建到完成,中间可能经历等待、执行中、失败重试、暂停、取消等多个状态。如果状态管理没做好,任务就会丢或者重复执行。
我设计的状态机是这样的:
- pending:任务已创建,等待首次触发
- running:正在执行
- waiting:执行到一半,等待某个条件(比如等外部API返回)
- retrying:执行失败,等待重试
- paused:手动暂停
- done:成功完成
- failed:重试次数耗尽,最终失败
每个状态转换都要写数据库,并且记录转换时间和原因。这样出问题时,我能清楚地看到任务卡在哪一步、卡了多久。
注意:状态转换必须用数据库事务包起来,否则并发场景下会出现状态覆盖。我早期没加事务,两个worker同时抢一个任务,结果任务被执行了两次,产生了重复数据。
3.2 重试策略:指数退避加抖动
任务失败是常态,关键是怎么重试。我采用的是指数退避加随机抖动的策略。
具体来说,第一次失败后等 2 秒重试,第二次等 4 秒,第三次等 8 秒,以此类推,上限设到 5 分钟。同时每次等待时间加上一个随机抖动值,范围是等待时间的正负 20%。
为什么要加抖动?因为如果多个任务同时失败,不加抖动的话它们会在同一时刻集体重试,瞬间打满下游服务。加了抖动,重试时间就分散开了。这个技巧我是从分布式系统的经验里搬过来的,在AI任务场景同样适用。
重试次数上限我设的是 5 次。超过 5 次还失败,基本可以判定是任务本身有问题,继续重试只是浪费资源,不如标记为 failed,人工介入。
3.3 任务持久化的存储选型
任务数据我存在关系型数据库里,没上NoSQL。原因很简单:任务状态需要强一致性,而且查询模式很固定——按状态查、按创建时间查、按任务ID查。关系型数据库完全够用,事务支持还省心。
表结构大致是这样:
CREATE TABLE tasks ( id VARCHAR(36) PRIMARY KEY, type VARCHAR(50) NOT NULL, payload TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', retry_count INT NOT NULL DEFAULT 0, next_run_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_error TEXT );next_run_at这个字段是关键。调度器只需要轮询"next_run_at 小于当前时间且状态为 pending 或 retrying"的任务,取出来执行即可。简单直接,不需要复杂的调度算法。
3.4 一个真实的持久任务案例
我让助手做过这样一件事:连续七天,每天早上九点抓取某个公开数据源的最新条目,去重后存入本地,第七天生成一份汇总报告。
这个任务拆解下来是这样的:
- 主任务:类型为
weekly_digest,创建时设定首次触发时间为明天九点 - 每次触发后,执行抓取和去重逻辑,然后创建一个新的子任务,触发时间为后天九点
- 第七次触发时,不再创建子任务,而是执行汇总逻辑,然后标记主任务完成
这种"任务自己创建后续任务"的模式,比在一个任务里循环等待要可靠得多。因为每次执行都是独立的,失败了只影响当次,不会把整个链条拖垮。
实操心得:创建后续任务时,一定要做幂等检查。我遇到过因为重试导致同一天创建了两个后续任务的情况,结果数据抓了两遍。后来在创建前先查一下"是否已存在相同类型且触发时间相近的pending任务",有就跳过。
4. 沙箱:让AI跑代码,但别让它把家拆了
4.1 隔离级别的选择
沙箱的隔离级别有好几档,从轻到重大致是:进程级隔离、容器级隔离、虚拟机级隔离。
进程级隔离最轻,用子进程跑代码,限制一下资源就行。但问题是,子进程和主进程共享内核,一个提权漏洞就可能逃逸。对于AI生成的、来源不可控的代码,这个级别不够。
虚拟机级隔离最重,每个任务起一个完整虚拟机,安全是安全了,但启动慢、资源占用大,跑个几秒钟的脚本要等半分钟启动,体验太差。
我最终选的是容器级隔离。启动速度在秒级,资源可以精细限制,安全边界也足够清晰。对于绝大多数AI代码执行场景,这个级别是性价比最高的。
4.2 容器配置的硬性约束
容器跑起来容易,但配置不当等于没隔离。以下是我踩过坑之后定下来的硬性约束:
- 网络默认关闭:除非任务明确声明需要网络,否则容器不接入任何网络。很多AI生成的代码会尝试访问外部服务,关掉网络能避免很多意外。
- 文件系统只读挂载:系统目录全部只读,只挂载一个临时目录可写,任务结束后销毁。
- 内存上限 512MB:超过就杀,防止内存泄漏拖垮宿主机。
- CPU 上限 1 核:防止计算密集型代码占满CPU。
- 执行超时 30 秒:超时强制终止,防止死循环。
- 禁止特权模式:绝对不能用 privileged 模式启动容器。
- 用户非 root:容器内用非 root 用户跑代码,进一步限制权限。
这些约束写在一个配置模板里,每次启动容器时套用。我建议把这些配置做成代码里的常量,不要散落在各处,方便统一管理和审计。
4.3 代码注入与结果回传
沙箱执行器对外暴露的接口很简单:传入代码和语言类型,返回标准输出、标准错误、退出码。
代码注入的方式我试过两种。一种是把代码写成文件挂载进容器,另一种是通过标准输入传给容器内的解释器。第一种更灵活,支持多文件项目;第二种更简单,适合单文件脚本。我目前用的是第一种,因为有些任务需要附带数据文件。
结果回传要注意编码问题。AI生成的代码可能输出各种字符,如果编码处理不当,回传时会乱码。我的做法是统一用 UTF-8 编码,并且在回传前做一次截断——输出超过 100KB 就只保留头尾,中间省略。因为超长输出往往是死循环打印导致的,全量回传没意义还占带宽。
def execute_in_sandbox(code, language, timeout=30): # 伪代码示意,实际实现需根据容器方案调整 container = create_container( image=f"sandbox-{language}", memory_limit="512m", cpu_limit=1.0, network_disabled=True, read_only=True, tmpfs={"/workspace": "size=64m"} ) try: result = container.run(code, timeout=timeout) return { "stdout": truncate(result.stdout, 100_000), "stderr": truncate(result.stderr, 100_000), "exit_code": result.exit_code } finally: container.destroy()4.4 沙箱逃逸的常见路径与防范
虽然容器隔离已经比较安全,但有些坑还是要注意。
路径穿越:AI生成的代码可能包含../../etc/passwd这样的路径。防范方法是在容器内把工作目录设为根,并且用只读挂载限制系统目录。
资源耗尽:除了内存和CPU,还要注意文件描述符数量和进程数量。我遇到过AI写了个fork炸弹,瞬间把容器内的进程数打满。后来加了pids_limit限制,最多允许 64 个进程。
符号链接攻击:AI可能创建指向宿主机敏感文件的符号链接。防范方法是容器内不挂载任何宿主机敏感目录,临时目录用 tmpfs,与宿主机文件系统完全隔离。
注意:沙箱不是万能的。如果AI能控制容器的启动参数,它就能绕过限制。所以容器配置必须由调度器代码硬编码控制,绝不能把配置权交给AI。
5. 群聊:多个AI怎么聊出有用的结果
5.1 角色设定与入场规则
群聊的第一步是定义角色。每个角色需要明确三件事:身份、职责、发言风格。
比如我常用的一个组合:
- 提案者:负责提出方案,风格是发散、大胆,鼓励提出多种可能性
- 批评者:负责挑毛病,风格是严谨、挑剔,专门找逻辑漏洞和边界情况
- 执行者:负责把方案落地成具体步骤,风格是务实、注重细节
- 总结者:负责在讨论结束时汇总各方观点,风格是中立、精炼
角色设定写在配置文件里,每个角色一段提示词。提示词的质量直接决定群聊质量,这部分值得花时间打磨。
入场规则我设的是:提案者和批评者必入场,执行者按需入场(当讨论涉及具体操作时),总结者只在最后一轮入场。这样避免无关角色干扰讨论。
5.2 轮次控制与终止条件
群聊最大的坑是无限循环。两个AI互相赞同,或者互相反驳但谁也说服不了谁,就会一直聊下去。
我的控制策略是三层:
第一层,硬性轮次上限。默认最多 10 轮,超过就强制终止,由总结者收尾。
第二层,收敛检测。每轮结束后,计算本轮发言与上轮发言的相似度。如果相似度超过阈值(比如 0.85),说明讨论已经收敛,提前终止。
第三层,明确终止信号。在提示词里告诉AI,如果认为讨论已经充分,可以输出特定标记(比如[DISCUSSION_COMPLETE])。协调器检测到这个标记就终止。
三层叠加下来,基本不会出现无限循环。实测中,大部分讨论在 4 到 6 轮就收敛了。
5.3 上下文管理:别让对话历史撑爆窗口
群聊的上下文会随着轮次增加而线性增长。10 轮讨论下来,上下文可能上万字。如果全部塞进每次请求,token 消耗巨大,而且模型对超长上下文的注意力会下降。
我的做法是分层记忆:
- 近期记忆:最近 3 轮的完整发言,原样保留
- 中期记忆:3 轮之前的发言,压缩成摘要,每个角色一段
- 长期记忆:整个讨论的主题和关键结论,存向量库,按需检索
每次请求时,把近期记忆和中期记忆拼进上下文,长期记忆只在需要引用历史结论时才检索注入。这样上下文长度可控,信息又不丢失。
摘要的生成我用的是另一个轻量模型,专门做压缩,成本低速度快。摘要提示词很简单:"用一句话概括以下发言的核心观点,保留关键结论和分歧点。"
5.4 群聊实战:一次方案评审的完整记录
我让群聊做过一次"设计一个本地文件去重工具"的方案评审。参与角色是提案者、批评者、执行者。
第一轮,提案者给出了一个基于文件哈希的去重方案,思路是遍历目录、计算每个文件的哈希、按哈希分组、保留每组第一个。
第二轮,批评者指出问题:大文件计算哈希很慢,而且如果两个文件内容相同但修改时间不同,用户可能希望保留最新的而不是第一个。另外,硬链接和符号链接的处理没考虑。
第三轮,提案者修正方案:改用分块哈希,先比文件大小,大小不同直接跳过;大小相同再算哈希。保留策略改为可配置,默认保留修改时间最新的。硬链接和符号链接单独处理。
第四轮,执行者给出具体实现步骤:用os.walk遍历,用hashlib算哈希,用sqlite存中间结果避免重复计算,并给出了关键代码片段。
第五轮,批评者确认没有明显漏洞,但提醒要注意权限问题——没有读取权限的文件要跳过而不是报错。
第六轮,总结者汇总:方案确定为分块哈希加可配置保留策略,实现要点包括权限处理、链接处理、中间结果缓存。
整个过程六轮,耗时不到两分钟,产出的方案比我一个人想周全得多。这就是群聊的价值——不同角色的视角互补,能覆盖单人思维的盲区。
6. 记忆存储:让助手真的"记得"你说过什么
6.1 短期记忆与长期记忆的分工
记忆分两层。短期记忆是当前会话的上下文,存在内存或Redis里,会话结束就清。长期记忆是跨会话的知识,存数据库和向量库,永久保留。
短期记忆的关键是窗口管理。模型的上下文窗口有限,不能无限塞。我的策略是保留最近 N 轮对话,N 根据模型窗口大小动态调整。超出部分压缩成摘要,作为长期记忆存起来。
长期记忆的关键是检索质量。用户问一个问题,系统要从海量历史记忆里找出最相关的几条。这靠的是向量检索——把记忆内容转成向量,查询时算相似度,取 top K。
6.2 向量检索的实用参数
向量检索有几个参数直接影响效果:
- 分块大小:一条记忆切成多大一块。太小则语义不完整,太大则检索不精准。我试下来 256 到 512 个 token 比较合适。
- 重叠长度:相邻块之间的重叠部分,防止语义在边界处断裂。一般设分块大小的 10% 到 20%。
- 相似度阈值:低于这个阈值的检索结果直接丢弃,避免引入无关信息。我设的是 0.7,实测下来比较平衡。
- 返回数量:每次检索返回几条。太多会稀释关键信息,太少可能漏掉。我一般返回 3 到 5 条。
这些参数没有绝对最优值,得根据实际数据调。我的建议是先跑一批真实查询,看检索结果的相关性,再针对性调整。
6.3 记忆的写入时机与去重
记忆不是越多越好。无关记忆会干扰检索,降低信噪比。所以写入时机要控制。
我的写入规则是:
- 用户明确说"记住这个"时,立即写入
- 任务完成后,把任务结论写入
- 群聊讨论结束后,把最终方案写入
- 其他情况,只写短期记忆,不写长期
写入前还要做去重。如果新记忆和已有记忆的相似度超过 0.95,说明基本重复,跳过不写。这个阈值我设得比较高,因为语义相近但细节不同的记忆,可能都有价值,不该轻易合并。
实操心得:定期清理长期记忆很重要。我每个月会跑一次清理,把超过半年没被检索到的记忆归档或删除。记忆库太臃肿,检索速度会下降,而且老旧的、可能已经过时的信息会干扰新查询。
7. 踩坑记录:那些让我熬夜的瞬间
7.1 任务重复执行的根因排查
现象:同一个持久任务被执行了两次,产生了重复数据。
排查过程:先看日志,发现两个worker几乎同时拿到了同一个任务。再看数据库,任务状态从 pending 变成了 running,但两个worker都读到了 pending 状态。
根因:任务领取没有加锁。两个worker同时查询"状态为pending的任务",都查到了同一条,然后都去执行。
修复:领取任务时用数据库的原子操作。具体是UPDATE tasks SET status='running' WHERE id=? AND status='pending',检查影响行数,只有影响行数为1的worker才算领取成功。这个操作在数据库层面是原子的,不会出现两个worker同时领取成功的情况。
这个坑的教训是:任何涉及状态变更的并发操作,都要用原子操作或锁来保护。想当然地认为"查一下再改"是安全的,迟早会出事。
7.2 沙箱超时导致的资源泄漏
现象:跑了一段时间后,宿主机内存被占满,新任务起不来。
排查过程:查容器列表,发现有一堆已经退出的容器没被清理。再看代码,发现容器销毁逻辑写在try块里,但超时是通过抛异常实现的,异常直接跳过了销毁逻辑。
根因:资源清理没有放在finally块里,异常路径下清理逻辑不执行。
修复:把所有资源清理逻辑移到finally块,确保无论正常结束还是异常退出,容器都会被销毁。同时加了一个兜底清理任务,定期扫描并销毁超过一定时长的孤儿容器。
这个坑的教训是:资源清理必须放在 finally 里,这是铁律。另外,兜底清理机制也很重要,因为代码总有考虑不到的情况。
7.3 群聊陷入客套循环的破解
现象:两个AI开始互相夸奖,"你的观点很有见地""谢谢,你的补充也很重要""哪里哪里,还是你的分析更深入",循环了十几轮。
排查过程:看发言记录,发现两个角色的提示词里都有"保持礼貌"的要求,导致它们把礼貌放在了讨论目标之上。
根因:提示词设计有问题。礼貌是好事,但在讨论场景下,过度礼貌会阻碍有效讨论。
修复:修改提示词,明确告诉角色"讨论的目标是解决问题,不是社交。直接指出问题,不需要客套。如果同意对方观点,直接说'同意'然后继续推进,不要展开赞美。"同时加了收敛检测,相似度超阈值就强制终止。
这个坑的教训是:提示词要明确优先级。告诉AI什么最重要,什么可以牺牲。模糊的提示词会导致AI在多个目标之间摇摆,产生意想不到的行为。
7.4 上下文超长导致的响应质量下降
现象:群聊进行到第八九轮时,AI的发言开始变得空洞、重复,甚至答非所问。
排查过程:检查请求的上下文长度,发现已经接近模型窗口上限。模型在超长上下文下,注意力被稀释,对近期内容的关注度下降。
根因:上下文管理策略太简单,只是简单截断,没有做摘要压缩。
修复:引入分层记忆机制,近期记忆保留完整,中期记忆压缩成摘要。同时把硬性轮次上限从 15 轮降到 10 轮,避免讨论拖得太长。
这个坑的教训是:上下文不是越长越好。超过一定长度后,增加上下文反而会降低质量。该压缩就压缩,该截断就截断。
8. 部署与运维:让这套系统稳定跑起来
8.1 服务拆分与部署方式
整套系统我拆成了三个独立服务:调度服务、沙箱服务、群聊服务。每个服务可以独立部署、独立扩容。
调度服务是无状态的,可以起多个实例,靠数据库的原子操作协调。沙箱服务是有状态的(管理容器),每个实例管理自己启动的容器,实例之间不共享。群聊服务也是无状态的,但需要连同一个记忆存储。
部署方式我用的容器编排,三个服务各一个部署单元,共享一个数据库和向量库。资源限制方面,沙箱服务给的内存最多,因为要跑容器;调度和群聊服务内存需求不大。
8.2 监控指标与告警阈值
没有监控的系统等于裸奔。我重点监控这几个指标:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 任务队列长度 | 待执行任务数 | 超过 100 告警 |
| 任务平均执行时长 | 从领取到完成的时间 | 超过 5 分钟告警 |
| 任务失败率 | 失败任务占比 | 超过 10% 告警 |
| 沙箱容器数 | 运行中的容器数量 | 超过 50 告警 |
| 沙箱平均执行时长 | 代码执行耗时 | 超过 20 秒告警 |
| 群聊平均轮次 | 讨论收敛所需轮次 | 超过 8 轮告警 |
告警我走的是邮件加即时消息双通道。邮件用于记录,即时消息用于及时响应。阈值不是拍脑袋定的,是跑了一周基线之后,取正常波动范围的上限。
8.3 日志规范与问题定位
日志我分三个级别:INFO 记录正常流程,WARN 记录可恢复的异常,ERROR 记录需要人工介入的问题。
每条日志都带 trace_id,从任务创建到执行完成,全链路可追踪。这样出问题时,我拿一个 trace_id 就能把整个链路串起来看。
日志格式我用的是结构化日志,JSON 格式,方便后续做聚合分析。关键字段包括:时间戳、级别、trace_id、服务名、任务ID、消息内容。
实操心得:日志里不要打敏感信息。AI生成的代码可能包含密钥、密码之类的内容,如果原样打进日志,泄露风险很大。我的做法是在日志输出前做一次脱敏,把疑似密钥的字符串替换成
[REDACTED]。
9. 后续可以继续折腾的方向
这套系统跑了大半年,基本稳定了。但有几个方向我还在琢磨。
沙箱的性能优化。目前每次执行都要起一个新容器,启动开销在秒级。如果任务量大,这个开销累积起来很可观。我在考虑用容器池,预先起一批容器待命,任务来了直接分配,用完归还而不是销毁。这样能把启动开销降到毫秒级。
群聊角色的动态调整。目前角色是固定的,入场规则也是写死的。我在想能不能让系统根据任务类型自动决定拉哪些角色入场。比如代码类任务多拉一个"测试者"角色,文案类任务多拉一个"润色者"角色。这需要一套角色能力描述和任务类型匹配的逻辑。
记忆的主动遗忘。目前记忆只增不减,靠定期清理。但有些记忆是应该主动遗忘的,比如已经过时的配置信息、被推翻的结论。我在研究怎么让系统自动识别"应该遗忘"的记忆,而不是等人工清理。
跨设备的任务同步。现在任务数据存在一台机器上,如果我想在另一台设备上查看任务状态,得远程连过来。如果能做一个轻量的同步机制,让任务状态在多设备间自动同步,体验会好很多。
这些方向都不急,慢慢来。反正系统是自己的,想怎么改就怎么改,这种掌控感是现成产品给不了的。