skip to content
tlj 的工程笔记
← 项目

HITL 办公 Agent SaaS:可恢复状态机 + 接口驱动架构

个人

一个支持人在回路(Human-in-the-Loop)的办公 Agent 示例工程。技术含量在「WAIT_USER/resume 可恢复状态机 + asyncio.Event 协程唤醒 + 40 条 pytest 覆盖安全与状态机全分支」。诚实在前:这是 AI 辅助开发的项目,我主导架构、任务拆解、review 与多轮安全加固,不是逐行手写。

时间
2026-03 ~ 2026-06
角色
架构设计与任务编排(AI 辅助开发,真人主导设计 / review / 安全加固)
成果
5 阶段可恢复状态机(IDLE→EXECUTE→WAIT_USER→resume→DONE),40 条 pytest 覆盖 JWT 伪造/过期、Fernet 加解密、沙箱目录穿越、状态机全分支;GitHub Actions 双 job CI

先把话说在前面:这是个 AI 辅助开发的项目——所有 commit 是 agent 跑出来的,我的真实角色是定架构、拆任务、review、和多轮安全加固迭代,不是逐行手搓。把它当「我设计并把控、AI 落实现」的工程来看,别当手写成果。下面写的技术含量是仓库里实打实能跑、测试能过的代码。

它是什么:一个办公场景的 AI Agent 示例工程。用户上传 Office 文件(Word/Excel/PDF),通过 WebSocket 给 Agent 派任务,Agent 跑 ReAct 循环调工具操作文件;过程中若缺前置条件(比如还没传文件)会挂起请求用户介入,用户补齐后唤醒 Agent 续跑——这就是「人在回路」。

核心:HITL 可恢复状态机(最硬的一块)

办公任务有前置依赖(得先有文件才能操作)。Agent 跑到一半发现没文件,不能直接报错终止,而要挂起等用户补齐。怎么做到「挂起 - 唤醒」而不是轮询:

这是协程级的挂起 - 唤醒。并发安全靠 asyncio.Lock 保护事件字典,事件所有权交给 start()finally 幂等清理,解决「事件泄漏 / 重复唤醒」。

工具调度:假设 LLM 输出不可信

Agent 调工具前做三道校验——① 未知工具名拦截;② 按 schema 的 properties 白名单过滤参数(丢掉 LLM 乱塞的多余 key);③ required 必填缺失拦截;调度内部还剔除重复的 user_id 避免「重复关键字参数」报错。整套设计的前提就是LLM 的工具调用参数可能乱来,工具层必须挡

ReAct 主循环还有两个防爆细节:消息历史滑动窗口(超长时保留所有 system + 最近非 system,防上下文无限膨胀)、单次工具结果 >6000 字符截断(防爆 token)。

接口驱动:换厂商不动业务代码

typing.Protocol 定义 StorageProvider / LLMProvider / OfficeAPIProvider 三套抽象,ProviderFactory 按配置装配实现:存储切 local/OneDrive、LLM 切智谱 GLM/OpenAI 兼容、office 切 local/mock/Graph。换 LLM 或存储后端只改 config + 加一个 adapter,DI 容器统一装配。

安全设计(4 条,每条解决一个具体威胁)

真实坑(现象 → 根因 → 解法,从 22 个 PR 里挖)

真实数字

40 条 pytest(全用 Fake Provider,不触真实 LLM/网络/磁盘):状态机全分支 9(含无文件→WAIT_USER→resume→DONE、resume 动作不匹配不唤醒、达 max_steps 告警)、安全 9(JWT 伪造/过期返 None)、crypto 5(Fernet 往返、错密钥失败、生产无 key 强制抛错)、本地存储 7(路径成分剥离、穿越抛错、用户隔离)、工具注册 7、状态枚举 3。GitHub Actions 双 job CI:后端 pytest(Python 3.11)+ 前端 tsc -b && vite build(一次类型检查 + 打包验证),push main 和任意 PR 触发。

诚实边界

小结

这个项目最值得看的是那个 HITL 可恢复状态机——它解决的是「Agent 长流程中途需要人类输入」这个真问题,用协程级的挂起 - 唤醒而不是轮询。以及那一整段诚实边界:是 AI 辅助开发、真实 LLM 和 OneDrive 都没端到端验证、不支持水平扩展——能跑过的就是那 40 个测试覆盖的部分,没验的就明说没验。工程骨架和安全加固是真的,但我不会把一个 mock 层能跑的脚手架说成上线产品。