
这个系列写到第九篇前面几篇把输入框、会话管理、代码生成管线都打通了但一直有个问题没彻底解决前端点一次“生成”后端就同步等AI返回结果运气好十几秒运气差能卡到超时。这一篇专门把“启动”和“等结果”拆开先讲清楚为什么不拆不行再给完整实现。如果你正在自己写AI Code终端、IDE插件、命令行辅助工具或者想理解现代终端系统里“异步任务”是怎么设计的这篇值得看完。先说结论在AI编码工具链里同步等待是大忌。AI生成一段代码可能需要几十秒期间用户干不了任何事HTTP连接随时可能断日志看不到任务取消不了一旦模型接口卡住整个服务跟着完蛋。把“启动”和“等结果”变成两个阶段所有问题立刻缓解。1. 为什么非要把启动和等结果拆开1.1 同步等待的三个致命伤我早期原型里是直接在前端请求里同步调AI CLI伪代码就三行拿到提示词、执行命令、把返回结果塞进响应。第一版跑通的时候还挺开心但真正开始多人试用问题全暴露了。第一是界面假死。一次代码生成任务本地跑个lint或者依赖分析就要十几秒模型API网络再慢点三十秒起步。浏览器里那个请求转圈能转到人崩溃用户以为系统坏了其实服务还活着只是这台机器被这个同步请求占住了。第二是网关超时。整个终端系统前面通常是有Nginx或者云网关的默认超时时间60秒特别常见。AI生成一旦超过这个时间网关帮我把连接断开但后端进程还在傻等结果等结果回来没人接只能丢进虚空。后端的任务也回不来前端拿不到任何数据两边彻底断联。第三是无路可退。同步模型没有“取消”这个操作。代码生成到一半发现有严重逻辑错误用户想停系统停不了用户改提示词想重新生成系统还在执行上一个任务AI卡住了没有超时保护只能重启服务。这三点任何一个在真实使用场景里都不可接受。我用点外卖打个比方大家就明白了同步等待等于你站在楼下等外卖小哥快递没到之前你啥都干不了中间外卖送丢了你也不知道拆开之后等于下单成功你就该干嘛干嘛没到就查一下状态到了再取餐实在等不了就取消订单都可以。1.2 拆开之后的收益模型把启动和等结果拆开之后整个系统变成了“任务异步执行”模型收益太明显了。用户侧点击生成会立刻拿到一个任务ID前端马上渲染出“运行中”的界面用户可以继续在编辑器里写代码、改提示词、开新的任务面板系统后台自己去跑。前端通过轮询或者事件流拿状态即可。后端侧每个AI任务是一个独立子进程彼此隔离。一个任务里AI CLI崩了顶多这个任务失败主服务和其他任务完全不受影响。这比在同一个进程里开线程跑安全太多线程模型下C扩展段错误能直接带走整个服务。运维侧每个任务有独立日志文件有状态流转记录有开始和结束时间戳失败原因一查日志就清楚。同步模型下出了问题是很难排查的因为你根本不知道任务执行到哪里了。所以拆开不是增加复杂度是给整个系统装上仪表盘。2. 整体设计进程、状态机与任务对象2.1 为什么选子进程而不是线程做后台执行方案选型的时候我第一个想到的是用Python的threading开线程跑AI调用写起来简单代码量也少但深入验证之后发现几个绕不开的问题。线程最麻烦的一点是没法真正“打断”。某些AI CLI底层用了不可中断的系统调用或者卡在某个C库函数里Python线程根本没法强制停止只能等它自己醒遥遥无期。子进程就不一样操作系统的信号机制天然支持终止发一个SIGKILL进程就没了干净利落。线程没有独立的退出码。AI CLI执行失败要区分是模型返回错误、超时、还是代码语法错误子进程退出码可以快速判断。线程模型下异常处理全靠抛异常和捕获任务一多状态管理非常混乱。还有隔离性。AI编码工具内部有时候会读取用户目录、装插件、跑代码这些行为不适合跟主服务混在同一个内存空间里。子进程相当于一个沙箱边界外部调用出任何问题都在进程边界内消化主服务很安全。所以在整个AI Code终端系统里凡是执行外部命令的环节我都统一走子进程模型。主服务用Python子进程用subprocess.Popen启动谁执行谁负责启动方只管记录、轮询、回收。2.2 任务状态机设计任务状态机是整个后台执行的神经系统。我定义了一套简洁的状态处理所有任务流转场景。状态含义触发条件STARTING进程刚启动还没确认存活Popen返回后立即进入RUNNING进程正常运行中第一次轮询发现进程还在SUCCESS成功结束轮询到进程退出退出码为0FAILURE失败结束轮询到进程退出退出码非0CANCELLED被用户取消外部调用cancel接口后进程被终止你可能注意到我没有用传统的PENDING和QUEUED状态因为这套系统里任务创建后立即启动不做排队调度。任务队列是后续要做的功能到时再加上WAITING状态。STARTING状态存在的意义在于Popen调用完之后进程可能存在极短的不稳定期注入依赖、初始化环境都在这个阶段。这个状态用来告诉前端“任务已创建马上开始”让界面及时反馈。判断任务结束时我用的是subprocess非阻塞轮询Popen.poll()返回None说明还在运行返回数字说明进程已退出这个数字就是退出码。退出码为0走SUCCESS分支其他走FAILURE分支。取消操作会主动给进程发信号进程被信号杀死后退出码是负数我们统一归到CANCELLED。2.3 任务对象里都要存什么任务对象本质上是一个字典或数据类包含足够信息供后续查询和排查。我维护的核心字段如下task_id全局唯一标识UUID前12位足够用且短。pid子进程PID查询进程状态和发信号用。procPopen对象句柄轮询、等待、关闭都必须持有。log_file日志文件的文件对象写入和关闭入口。log_path日志文件路径方便Web层读取尾部内容。status当前状态枚举值。exit_code退出码进程结束后才有有效值。start_time / end_time时间戳算耗时和清理过期任务。cmd实际执行的命令列表审计和重试用。workdir工作目录复现现场很关键。timeout超时阈值单位秒。日志为什么必须落盘而不是放内存一是进程退出后日志还在可以离线定位问题二是如果一次任务产生几百MB输出内存直接被打爆磁盘文件没这个压力三是Web层要支持日志偏移读取文件天然有这个能力。我在存储上做了一个10MB上限限制超过这个大小就开始覆盖最旧的日志防止日志无限增长把磁盘塞满。3. 核心实现TaskRunner 实战代码3.1 启动阶段Popen 关键参数下面这段代码是我TaskRunner类的核心骨架每一行都有讲究。import os import signal import subprocess import threading import time import uuid class TaskRunner: def __init__(self): self.tasks {} self._lock threading.Lock() def start(self, cmd, workdir., log_dir/tmp/ai-terminal-tasks): task_id uuid.uuid4().hex[:12] os.makedirs(log_dir, exist_okTrue) log_path os.path.join(log_dir, f{task_id}.log) # buffering0 全关闭缓冲日志实时落盘 log_file open(log_path, ab, buffering0) proc subprocess.Popen( cmd, cwdworkdir, stdoutlog_file, stderrsubprocess.STDOUT, shellFalse, start_new_sessionTrue, ) task { id: task_id, pid: proc.pid, proc: proc, log_file: log_file, log_path: log_path, status: running, exit_code: None, start_time: time.time(), end_time: None, cmd: cmd, workdir: workdir, } with self._lock: self.tasks[task_id] task return task_id这里最关键的是三个参数stdout和stderr都重定向到同一个日志文件而不是PIPE管道。用PIPE管道会遇到缓冲区阻塞问题子进程输出很多Python主进程如果不及时读取管道写端会堵死整个子进程就卡住了。直接落到文件子进程只管写不存在读的负担这是最省心的方案。shellFalse是第二个关键点。如果shellTrue实际启动的是/bin/sh你的命令字符串会经过shell解析一旦命令里有用户输入的特殊字符很可能被注入执行额外命令。AI终端系统里命令参数经常来自模型生成这个风险非常真实。用shellFalse并把命令做成参数列表list每个参数原样传递不经过shell解释安全且行为可控。start_new_sessionTrue是最容易被忽略但极其重要的一步。它让子进程成为新进程组的组长有自己的进程组ID。这样我们后续要取消任务时可以用os.killpg一次性杀掉整棵进程树而不是只能杀一个PID。AI CLI经常自己再拉起子进程比如node、python子脚本、npm安装如果没有进程组隔离杀父进程后留下一堆孤儿结果就是任务没取消干净。3.2 轮询与回收阶段启动之后不能阻塞等结果要用轮询方式检查状态。轮询方法如下def poll(self, task_id): task self.tasks.get(task_id) if not task: return None, None if task[status] ! running: return task[status], task[exit_code] rc task[proc].poll() if rc is None: return running, None # 调用wait回收子进程避免僵尸 task[proc].wait() task[exit_code] rc task[end_time] time.time() task[log_file].close() if rc 0: task[status] success else: task[status] failure return task[status], rc这段代码逻辑很直白poll()返回None就是还在运行返回数字就代表进程已退出。进程退出后必须马上调用wait()回收这叫收尸。如果进程结束了你不wait它会变成一个僵尸进程在系统进程表里占一个位置大量堆积会耗尽PID资源最后系统连新进程都创建不了只能重启。日志文件也要及时关闭。之前打开了文件对象持有文件描述符进程结束后如果一直不关文件描述符泄漏跑一周后系统会报toBeManyOpenFiles错误。我在任务对象里保存log_file就是为了在这个时刻能准确关掉它。轮询频率也有讲究。后端保留任务管理器线程或者用API触发每次调用poll来刷新状态。前端轮询接口建议间隔1秒到2秒后端内部轮询间隔可以做短一些0.5秒就够。太频繁会导致锁竞争严重太慢用户体验会觉得不够实时。我自己实际测试下来1秒轮询是体验和负载的甜蜜点。3.3 超时与取消代码生成任务经常出现“AI在思考人生”的情况也就是模型API一直不返回或者CLI在等待输入无限期卡住。必须给每个任务设置超时。超时我做了两层软超时和硬超时。def cancel(self, task_id): task self.tasks.get(task_id) if not task or task[status] ! running: return False try: os.killpg(os.getpgid(task[pid]), signal.SIGTERM) except ProcessLookupError: pass # 5秒后还没退出就强杀 def force_kill(): time.sleep(5) task self.tasks.get(task_id) if task and task[status] running and task[proc].poll() is None: try: os.killpg(os.getpgid(task[pid]), signal.SIGKILL) except ProcessLookupError: pass threading.Thread(targetforce_kill, daemonTrue).start() return Truecancel操作先发SIGTERM让进程优雅退出给它5秒时间清理临时文件和子进程。如果5秒后还赖着不走再发SIGKILL强杀。这里再次用os.killpg而不是os.kill就是要把所有关联子进程一起杀掉。软超时指任务运行超过一个阈值我设成15分钟系统会记录一个warned字段前端提示用户任务超时但还在运行。硬超时是20分钟到点直接调用cancel流程。软硬两次提醒是为了避免用户误会任务卡死其实AI还在慢慢返回。超时机制由任务管理器统一负责我单独起了一个监控协程每隔30秒扫描所有运行中任务检查是否超时。不要在start里为每个任务开一个sleep线程任务一多线程数量爆炸。4. 接入 Web 层真正把拆开落地4.1 三个 API 设计后台执行要落地到前端必须配套API。我设计了三个核心接口分别对应“启动”、“查询”、“取消”POST /api/tasks GET /api/tasks/{task_id} POST /api/tasks/{task_id}/cancel启动接口收到任务后立刻返回task_id和初始状态。查询接口返回状态码、退出码、耗时、日志文件路径。取消接口返回取消结果。这套接口非常薄核心就是调用TaskRunner。from flask import Flask, request, jsonify app Flask(__name__) runner TaskRunner() app.route(/api/tasks, methods[POST]) def create_task(): req request.get_json() # 这里必须做命令白名单校验和参数校验 cmd req.get(cmd) workdir req.get(workdir, .) task_id runner.start(cmd, workdir) return jsonify({task_id: task_id, status: running}), 202 app.route(/api/tasks/task_id, methods[GET]) def get_task(task_id): status, rc runner.poll(task_id) task runner.tasks.get(task_id) return jsonify({ task_id: task_id, status: status, exit_code: rc, # 省略 end_time 等其他字段 }) app.route(/api/tasks/task_id/cancel, methods[POST]) def cancel_task(task_id): ok runner.cancel(task_id) return jsonify({cancelled: ok})启动接口返回202而不是200语义上是“已接受但尚未处理完成”非常匹配异步任务模型。命令白名单校验必须做AI生成的命令不能原样信任只允许执行指定目录下的可执行文件限制工作目录范围防止越权访问系统关键文件。4.2 日志读取细节任务运行期间用户最想知道的是“现在跑到哪了”。日志接口我用偏移量续传设计GET /api/tasks/{task_id}/logs?offset0返回从offset字节开始的新日志内容同时返回新的offset。前端拿着新offset再请求即可。流式拉日志体验接近实时终端。读取日志文件时要注意编码问题。AI CLI可能会输出非UTF-8字符所以我选择以二进制方式读取前端拿到后用TextDecoder解码。日志按字节偏移不会破坏分段内容的位置关系。尾部读取最近N行在调试时很常用。文件可能几十MB从头读到尾会卡正确做法是反向读取文件末尾几KB然后切割出最后N行。这个技巧在线上排查问题的时候特别实用但不要在正常业务请求里每次都做全文件读取。4.3 多进程部署下的任务状态共享用单进程Flask开发跑demo没问题一旦用gunicorn起4个worker问题立刻出现TaskRunner里的self.tasks是进程内存每个worker各有一份用户请求落在worker1创建任务查询请求落在worker2根本找不到。我在这上面踩过坑花了半小时才定位到是worker不共享内存。解决方案是任务状态落到共享存储。轻量场景一个SQLite文件就够任务表存task_id、pid、status、time等字段日志还是走文件系统。每个worker启动时扫描SQLite里running状态的任务同时检查对应PID是否还活着如果PID已经不存在就更新为FAILURE这是“孤儿进程回收”机制。如果想做得更专业用Redis存状态信息TTL设为任务最大时长配合pub/sub推送日志流体验可以接近Visual Studio Code的终端。但SQLite方案胜在简单可靠对本地终端系统完全够用。5. 踩坑实录与排查技巧5.1 输出不进日志文件第一个高频坑是任务明明在跑日志文件却始终是空的。首次遇到我还以为子进程压根没起来结果进程列表里一切正常。罪魁祸首是Python的输出缓冲。Python的print默认行缓冲在非交互式终端里会变成全缓冲输出量没到8KB就不写盘。AI CLI内部如果用Python写日志日志文件可能延迟很久才出现内容。解决办法有几个效果最好的是在启动命令时给解释器加-u参数强制无缓冲或者设置环境变量PYTHONUNBUFFERED1。对于其他语言编译的程序可以用stdbuf工具设置缓冲行为。如果我启动的就是python脚本最简单的是在cmd列表里把python换成python -u其他的不用动。5.2 僵尸进程堆积用子进程跑任务时间长了ps aux里出现一大堆defunct进程这是没有及时wait()回收导致的。我在早期实现里只poll判断退出码忘了wait结果一天下来系统进程表里躺着几百个僵尸。解决办法就是在poll返回退出码之后立刻调用wait()。另外要注意如果主服务意外崩溃子进程会变成孤儿进程由init进程接管这些孤儿通常在父进程退出后被init重定向但不一定能自动回收干净极端情况下需要写一个看门狗脚本定期清理残留。5.3 取消不干净取消功能做完测试时发现一个诡异情况明明在任务列表里把主任务kill掉了日志还在增长因为AI CLI启动了一个子进程继续跑。我只kill了PID根本没碰到它儿子。这就是为什么start_new_sessionTrue和os.killpg这么重要。让子进程独立成进程组取消的时候对整个进程组发信号一个漏网之鱼都没有。硬超时还要注意SIGKILL也会被某些不可中断的内核态操作延迟所以强制清理要留够时间窗口。5.4 AI CLI 拒绝在非交互式终端里运行最近对比command code ai和opencode这两种CLI工具时发现不少AI编码工具检查了stdout是否是tty判断当前是否为交互式终端。stdout被重定向到日志文件时isatty返回False工具直接降级模式或者拒绝运行。continue这个开源AI code agent也有类似行为当检测到非交互式环境某些功能会关闭比如进度条、彩色输出、交互式选择器。我的日志文件里全是纯文本用户体验差一大截。解决办法是给这类任务分配一个伪终端。Python的pty模块可以创建伪终端对子进程的stdout接在伪终端slave端master端读出的数据会带终端控制序列再写入日志文件。这样AI CLI以为自己运行在真实终端里所有功能都正常开放。伪终端方案还有个附带好处控制字符记录在日志里还可以还原色彩做Web终端展示效果很好。5.5 Ubuntu 下“打不开终端”的关联坑有用户反馈在Ubuntu桌面环境里AI生成的任务试图打开一个新终端窗口时经常失败表现就是“打不开终端”。排查后发现问题不在任务执行逻辑而在环境变量丢失。后台子进程从服务继承的环境变量里没有DISPLAY或者DBUS_SESSION_BUS_ADDRESS图形终端程序根本连不上显示服务器。从系统服务启动的子进程尤其容易丢这些变量桌面应用要求完整的用户会话上下文才能打开新窗口。解决这类问题要先确认环境变量是否完整启动命令前把关键变量打印到日志再判断是在哪一步丢的。如果是通过SSH或服务管理器启动需要显式带出登录会话环境变量。我的经验是涉及GUI扩展的任务工作目录和环境变量都要从用户会话快照里重建不能图省事只用系统的默认环境。6. 经验收尾拆开“启动”和“等结果”之后我整套AI Code终端系统的稳定性上了一个大台阶。前端不再被长任务拖死用户可以并行发起多个任务日志随时可见失败可以定位取消干净利落整个系统第一次有了“工程产品”的样子。事后反思这个设计思路本质上是一种最简单的异步任务编排模式。终端系统里凡是耗时操作都应该默认走异步代码生成是、依赖安装是、测试执行是、模型下载也是。把这些都归到一个统一的后台执行框架里后续加一个失败自动重试、一个任务结果缓存都是很自然的事。后面再写一篇我会聊聊日志流式推送和Web终端渲染的部分那才是真正让前端看起来像本地终端的关键。