把模型接口接入应用并不复杂,困难的是持续运行后的执行控制:哪些输入值得调用生成模型?工具如何异步执行?外部工具数量增长后,如何控制上下文?历史信息如何保留,又不让每次请求无限膨胀?
本文以开源项目 InSoulForge 的现有实现为例,介绍一套基于 C++23、Drogon、Lua 和 SQLite 的 Agent 执行架构。讨论限定在模型调度、异步运行时、工具发现与记忆管理,不涉及具体消息平台及其接入方式。
项目仓库:https://github.com/DreamDonghao/insoulforge。以下内容描述当前实现,不是所有 Agent 系统都必须采用的通用规范。
一、将决策与文本生成拆开
事件驱动的 Agent 不一定需要响应每一个输入。若所有输入都交给主力模型分析并生成结果,决策成本与生成成本便混在一起,也难以分别优化。
InSoulForge 将主要职责拆为三部分:
| 组件 | 职责 | 输出 |
|---|---|---|
| Jev | 对有明确选项的高频问题做优先判断 | 结构化选项、概率分布与置信度 |
| Router LLM | 未启用 Jev 或 Jev 无法确定时的决策 | 是否执行及执行策略 |
| Executor | 生成回复、规划并调用工具 | 工具调用或最终输出 |
它们不是每次都顺序执行的三个固定步骤。明确规则可以直接决定是否进入生成流程;Jev 有效且足够确定时,也不必再请求 Router LLM。
二、Jev:用专用模型处理结构化决策
不只是要求聊天模型输出 JSON
Jev 是 TypeSafe AI 的结构化决策模型,属于其称为 System One 的模型体系。它不生成开放式聊天正文,而是针对输入状态和带类型的问题返回结构化结果。Choice 类型提供选中项、各选项概率及置信度;另有用于评分和条件判断的问题类型。TypeSafe 官方介绍
对于“执行还是跳过”“选择什么语气”“采用哪个长度档位”这样的任务,调用方可以明确给出候选项及判定标准,让程序直接消费结果。相比生成一段分析再提取结论,这种接口将模型职责限定在清晰的决策边界内。
这并不意味着普通聊天模型不能提供结构化输出,而是两者的产品目标不同:Jev 专门面向软件中的判断任务,生成模型则继续负责开放式表达和复杂任务执行。OpenRouter 模型说明
在代码中用置信度控制交接
当前实现向 Jev 提交上下文、触发消息标识和多个 Choice 问题,分别判断是否执行、语气以及输出长度档位。执行选项包含 reply、skip 和 unclear。
程序校验结果后再决定流程:
输入事件 → 明确规则检查
↓ 需要模型判断
Jev(可选)
├─ 有效且达到置信度阈值 → 采用决策
└─ 不确定、响应无效或请求失败 → Router LLM
↓
需要执行时进入 Executor
默认置信度阈值为 0.60,可在管理后台调整。程序读取的是决策的 confidence,不是 probabilities.reply。二者表示不同字段,不能相互替代,更不能将阈值直接解释为系统的实际正确率。
收益来自减少不必要的生成式判断
设每个适用事件都会先请求 Jev,平均费用为 Cj;其中比例 p 的事件需要 Router LLM 兜底,平均费用为 Cr。忽略其他差异时,决策阶段的期望费用近似为:
使用 Jev:Cj + p × Cr
只用 Router LLM:Cr
只有当 Cj + p × Cr < Cr 时,这一阶段才具有费用优势。这里尚未计入 Executor、重试及其他处理成本,也不能据此直接推导整个应用的成本降幅。
延迟同样存在取舍:直接采纳 Jev 结果的事件可以跳过 Router 请求,兜底事件则多了一次前置请求。因此应观察实际请求耗时、兜底比例和决策质量,再调整阈值,而不是直接套用模型厂商的宣传倍数。
三、以协程组织执行,而不是阻塞事件循环
后端使用 C++23 和 Drogon,以 drogon::Task<T> 表达可等待的异步操作。模型请求、HTTP 工具调用等 I/O 可以通过 co_await 串联,让控制流保持接近同步代码的阅读方式。
协程的价值是将等待与线程占用分离,而不是自动把任意函数变成非阻塞操作。同步文件操作、长时间计算或阻塞式第三方调用,放进协程后仍然会占用当前线程。
因此,执行路径需要区分三种状态:
- 正在计算:当前线程实际执行代码。
- 等待异步 I/O:挂起协程,完成后恢复。
- 需要后台处理:由独立的任务生命周期管理,不能假设使用协程就已经隔离了计算负载。
同一会话的回复流程按状态排队处理。每次回复处理取得消息快照,并保留触发消息标识,避免将“用于理解语境的上下文”和“本次需要处理的事件”混为一谈。
四、给工具调用明确的执行预算
Executor 采用迭代式工具调用:请求模型、执行返回的工具、附加结果,再进入下一轮。没有边界时,查询失败或模型反复尝试都可能延长调用链。
当前实现设置最大迭代轮数,默认 8 轮,并在每次模型请求中提供当前轮次及剩余轮数。同轮包含多个工具调用时只计一轮,因此它是模型迭代预算,不是严格的工具调用次数上限。
最后一轮只开放回复或不回复等收尾工具;后端也会拒绝本轮未开放的工具,而不只是依靠提示词约束。配置为 1 轮时,首轮即只能收尾。
工具结果尽量包含具体失败原因,使模型能够区分参数错误、网络异常和远端业务失败。上下文保留必要的工具名称、参数与处理状态,但不长期保存完整工具结果,也不重复记录最终回复工具已有的正文。
这些机制限制单次执行的扩展范围,但不等同于费用硬上限:输入长度、模型价格和同轮工具数量仍会影响实际开销。
五、Lua 与 C++ 异步运行时如何协作
Lua 扩展的目标是让工具逻辑可以独立编辑和加载,而无需重新编译主程序。工具通过名称、描述、JSON Schema 参数和脚本定义对模型暴露能力。
关键问题不在于能否运行脚本,而在于脚本请求 I/O 时,宿主线程是否被阻塞。
当前实现通过 Lua 协程与宿主操作分发协作:
Lua 调用 http.request(options)
↓
coroutine.yield 返回操作描述
↓
C++ 校验并执行异步 HTTP 请求
↓ co_await 等待结果
C++ 将结果传回 Lua,恢复脚本
消息发送等宿主能力也采用操作分发方式。脚本不需要直接访问 C++ 对象布局,宿主则能明确控制哪些操作可用、参数如何校验,以及错误如何返回。
这种机制解决的是已暴露接口中的异步等待,并不让 Lua 成为安全沙箱,也不自动解决脚本中的死循环或长时间计算。脚本仍应作为受信任扩展管理;运行不可信代码需要另外设计隔离和资源限制。
六、MCP 工具按需展开,而不是全部注入
远程服务可能包含几十个工具。若每次请求都提交所有工具及完整参数 Schema,会增加输入长度,也使模型更难选择当前任务真正需要的工具。
当前 MCP 集成分为目录、查询和启用三个层次:
- 系统提示词只提供已启用服务的名称与用途描述。
- 模型使用
mcp_list_tools查询目标服务,获取分页工具摘要。 - 模型使用
mcp_enable_tools选择需要的工具,完整参数 Schema 从下一轮请求开始开放。
选择状态只属于本次回复,不在会话之间共享。后端检查本轮开放范围与工具定义版本,不允许模型在同一轮先启用、再直接调用尚未进入本轮定义的工具。查询分页大小、单次启用数量和单次回复工具总量均可配置。
当前客户端主要支持 Streamable HTTP 的工具发现与调用,不支持 stdio、OAuth 和完整 MCP 功能集。SSE 在完整响应结束后解析,不能处理所有长期连接场景。远程工具调用不自动重试,避免在结果不明确时重复产生副作用。
按需发现降低的是工具定义的常驻输入规模,并不构成权限隔离。服务是否可信、工具能访问哪些数据,仍需单独审查。
七、耗时任务:将启动与完成分成两个事件
图片生成等操作不适合一直占据当前工具迭代流程。当前设计让工具先返回任务已启动,再由后台任务负责执行和交付结果。
工具启动任务 → 返回任务标识 → 当前回复可以继续或结束
↓
后台执行与交付
↓
生成系统完成事件
↓
触发新的回复处理
同一会话同时只允许一个此类耗时任务,其他会话独立处理。完成后,结果及必要描述进入上下文,系统事件告知执行状态;失败也通过事件返回,让后续模型基于实际结果回应。
运行中任务不跨重启恢复。这是当前面向可重新发起任务的取舍,不适合直接用于要求持久化交付、严格审计或事务补偿的流程。
八、分层记忆与批次提交
近期上下文、短期记忆和长期记忆承担不同职责:近期上下文保留原始对话,短期记忆保存整理后的信息,长期记忆通过可选的 Embedding 服务向量化,存入 SQLite 后按余弦相似度检索。
长期记忆既可以在预处理时自动召回,也可以通过工具主动查询。未配置 Embedding 时,仍保留短期记忆能力。
维护流程采用“先计算,后提交”:
达到触发数量 → 复制最旧消息批次及补充上下文
↓
提取和整理记忆 → 准备向量 → 计算互动评分
↓ 全部成功
SQLite 事务写入结果 → 提交成功后移除对应旧消息
每个会话最多存在一个进行中的维护批次。网络请求在数据库写事务之外完成,避免在等待模型时持续持有写锁。任一准备步骤失败,结果不写入,消息保留,等待后续输入重新触发。
事务保证的是本次数据库写入的原子性,并不覆盖消息列表更新和进程崩溃之间的全部状态。当前没有持久化维护任务,也不宣称提供崩溃条件下的 exactly-once 语义。
这减少了恢复队列和中间状态的复杂度,但也明确了边界:若业务要求严格去重、跨重启恢复或可靠任务交付,就需要增加任务标识、持久化状态及幂等设计。
九、可观察性比单一成功率更有用
管理后台使用 Vue 3,集中展示模型配置、工具状态、执行参数、日志和请求记录。对于这类多阶段系统,排查问题时应区分:
- Jev 是否成功返回,决策是否因置信度不足而兜底。
- Router 与 Executor 是否使用了预期模型。
- 工具是否实际调用,还是模型仅在文本中声称执行过。
- 远程操作是否完成,以及结果是否成功交付。
- 记忆计算是否成功,数据库结果是否提交。
HTTP 200 只表示请求在该层面成功,不能代替业务校验。空结果、缺少字段和无法交付的输出,都需要在对应层面识别。
请求记录也不是可以无条件公开的调试材料:提示词、工具参数和返回内容可能包含敏感数据,应限制访问,并在分享前检查脱敏情况。
总结
这套架构的重点不是把所有工作都交给更大的模型,而是拆分不同类型的责任:用 Jev 处理有边界的结构化决策,用 Router LLM 处理不确定情况,用 Executor 组织执行;通过协程等待 I/O,通过 Lua 和 MCP 扩展工具,通过批次处理与事务维护记忆。
这些设计提供了控制延迟、调用范围和系统复杂度的手段,但不自动保证更低成本、更高准确率或可靠交付。实际效果仍需要结合负载、模型表现和失败模式验证。
代码与实现细节见 InSoulForge 仓库、开发文档及 MCP 客户端文档。