ThunderAgent是我近期把 Dynamo 这套动态任务编排体系玩明白之后,顺手在其上磨出来的一个智能体框架。它的定位很直接:把原先靠 cron、定时脚本、人工盯屏的重复性工作,变成一段可描述、可恢复、可观测的自动化流水线。你可能好奇,为啥不直接用现成的 Agent 框架,非要自己攒一套?这篇文章就是想把这件事聊透。
先给不熟悉的朋友交个底。如果你在建筑信息化行业待过,听到" Dymano"可能会先想到 Revit 里的可视化编程插件,这里要说的是工程自动化方向的数据动态编排体系。ThunderAgent 不是一个庞大的平台,而是一个思路:把任务拆成节点、节点之间按依赖关系动态流转,配上一个大模型做语义理解和工具调度,再加上一层可靠的存储底座,让整个流程跑得动、断得了、恢复得起。它适合三类人:一是被各种手工报表和巡检折腾到想"摆烂"的后端或运维开发,二是想给团队搞一套轻量自动化底座、又不想背上一整个重量级框架的人,三是对 Agent 工程化落地细节感兴趣、想看看真实项目里有多少"坑"的同学。内容会偏工程实践,代码和配置我都尽量给完整。
1. ThunderAgent 的整体设计思路:为什么落在 Dynamo 上
这个项目最开始其实只有一个朴素的需求:有一堆数据处理任务,涉及采集、清洗、聚合、通知,每个环节的耗时差异很大,随时可能插入新的处理分支,而且任务跑到一半如果断了,得能接着跑而不是从头再来。市面上的 workflow 引擎太重,光配置中心、权限模型、部署方式就能折腾一整天;直接用脚本硬写,分支逻辑一多就变成"意大利面条",比维护还难受。所以我才把目光放到了 Dynamo 这套动态编排体系上。
1.1 Dynamo 到底是什么:动态编排内核加存储底座
我理解的 Dynamo 不单指某个存储产品,而是一套组合:底层是 DynamoDB 这类键值存储,提供低延迟的状态读写和 TTL 过期能力,上层是一个动态编排内核,负责任务的解析、调度、依赖执行和状态流转。ThunderAgent 里的每个任务,本质上就是这张动态任务图上的一个节点,节点之间通过事件或显式依赖触发,状态统一落到存储里。
选择这层底座的理由有三条。一是状态必须可重建。Agent 跑一个多步骤任务时,任何一步都可能因为外部接口超时、模型返回异常、进程重启而中断,有了持久化状态,重启之后能直接从上次断点继续。二是并发要可控。多个 Agent 同时处理不同任务时,需要一套轻量的分布式协调机制,键值存储的行级写入能力刚好够用。三是成本要低。DynamoDB 这类存储不需要专门运维,按量付费,对小团队非常友好。
1.2 为什么叫 ThunderAgent:快和恢复能力
名字里的 Thunder 不是营销话术,它对应两个设计目标:一是反应要快,接收到任务之后能在秒级内完成解析和首个动作的触发,而不是在框架启动、模型加载中就耗掉半分钟;二是恢复要快,任何节点失败都不该让整个流程"原地爆炸",而是快速定位、快速重试或跳过。
为了做到这两点,架构上做了几个取舍。任务解析和工具调用分离,解析结果可以缓存复用,避免同一个任务反复解析;工具层统一封装超时和熔断,单点故障不会拖垮全局;任务执行按阶段打快照,失败之后可以选择"从失败节点继续"而不是"从头重跑"。这些细节在后面会逐一展开。
ThunderAgent 的模块架构大致分四层:接入层负责接收任务指令(API、消息队列、定时触发),解析层由大模型理解用户意图并翻译成结构化任务描述,调度执行层维护任务图并驱动节点流转,存储层负责任务快照、运行日志、工具结果的持久化。层与层之间只通过明确定义的接口通信,这为后续替换模型、扩展工具集留了口子。
2. 核心模块拆解与关键实现:每个环节都要能回答"为什么"
开头说了一堆理念,这里落到代码层面。ThunderAgent 的核心模块可以拆成任务解析、工具注册、执行器、记忆与状态管理四块,每一块都有值得注意的细节。
2.1 任务解析:把自然语言变成可执行的任务描述
这一步解决的是"用户说人话,系统干实事"。设计思路是:不直接让模型自由发挥输出命令,而是约束它输出一个结构化的任务描述,包含任务类型、目标节点参数、约束条件和优先级。比如用户说"把昨天的错误日志汇总一下,按错误类型分类发邮件",解析层会输出一个包含action: analyze_logs、params: {date: "2025-01-17"}、notify: email的结构化结果。
关键点是输出格式的约束。自由文本输出在真实项目中很容易翻车——模型今天给你 JSON,明天给你 Markdown,后天给你一段散文。所以这里用了两层约束:第一层在 Prompt 里明确规定输出 JSON,并给一个 schema 示例;第二层在代码里做 schema 校验,不通过就自动重试一次。这种做法比我最初"信任模型输出"的版本稳定得多,线上解析失败率从最初的百分之七八降到了百分之一以内。
每个任务解析完成后会生成一个全局唯一的 task_id,它贯穿整个生命周期,日志、状态、工具调用结果都挂在这个 ID 下面。这个设计帮了大忙,排查问题的时候直接按 task_id 捞日志即可,不用猜是哪个环节出的问题。
2.2 工具注册中心:让 Agent 拥有一把"瑞士军刀"
Agent 光会理解任务还不够,得有工具去真正执行。ThunderAgent 里有一个工具注册中心,每个工具就是一个普通的 Python 函数,通过一个装饰器注册进来,框架会自动收集它的函数签名、参数说明、返回值结构,拼到模型的 Prompt 里。
@tool( name="query_logs", description="按时间范围和关键词查询应用日志,返回匹配的日志行列表", params={ "start_time": {"type": "string", "desc": "开始时间,格式 YYYY-MM-DD HH:mm"}, "end_time": {"type": "string", "desc": "结束时间,格式 YYYY-MM-DD HH:mm"}, "keyword": {"type": "string", "desc": "日志关键词,可空"}, }, ) def query_logs(start_time: str, end_time: str, keyword: str = "") -> list: # 实际查询逻辑 pass这个设计有几个容易被忽略的好处。第一,模型看到的工具说明书是动态生成的,新增工具无需改核心代码,注册完就能被模型感知;第二,工具的入参和返回值都走统一封装,方便拦截、审计、限流;第三,工具结果可以直接作为任务上下文的一部分存起来,即使后续模型重新生成回答,也能引用之前的工具返回。
需要强调一个安全层面的细节:工具权限要做最小化。我这里实现了一个简单的白名单机制,注册工具的时候声明执行所需的权限级别,解析层会校验任务声明的用途是否和工具的权限匹配。比如"读日志"类工具不能被执行成"删除日志"。这算是一个比较务实的防线,毕竟 Agent 的工具调用是自动触发的,一旦被提示词注入或者异常引导,后果比人工误操作严重得多。
2.3 执行器与状态机:任务跑起来的引擎
执行器的职责是驱动任务图上的节点。每个任务实例对应一个状态机,包含PENDING、RUNNING、WAITING_TOOL、COMPLETED、FAILED、CANCELLED这几个状态。节点开始执行时从PENDING变RUNNING,遇到需要外部调用的步骤变WAITING_TOOL,工具返回或者超时之后根据结果迁移到对应状态。
状态流转的逻辑不复杂,但有一个设计值得说道:节点的执行和依赖检查是分离的。任务图会先做一次拓扑排序,只有所有前置节点都进入终态(COMPLETED),当前节点才允许启动。这样避免了"一个节点失败了,后面的节点还傻乎乎往前冲"的问题。如果某个节点失败且没有配置重试策略,整条链路会进入FAILED并触发告警,但兄弟分支如果和它没有依赖关系,仍然可以继续执行——这就是动态编排相对于单体脚本的核心优势:部分失败不会导致全盘回滚。
执行并发模型上,我做的是"事件驱动 + 异步 IO"的方案,任务调度器本身是单线程的,实际耗时操作全部提交给异步执行池。这样调度器不会被某个慢工具调用阻塞,多个 Agent 实例可以共享同一个调度器。
2.4 记忆与状态管理:Dynamo 存储底座的活用
这一块是整个项目里最有"投入产出比"的部分。大模型本身不携带任务间的记忆,任何跨步骤的信息要么塞进上下文,要么落到存储里。ThunderAgent 的做法是:状态存 Dynamo,上下文滚动摘要。
任务状态表的字段很克制:task_id(分区键)、status、current_node、payload(当前节点的入参快照)、version(乐观锁版本号)、ttl(过期时间)、updated_at。我踩过一个坑:一开始把工具返回结果和中间变量全都塞进 payload,结果单行数据越写越大,读写的延迟和成本都上去了。后来改成 payload 只存节点入参,工具的大体积返回结果单独存到结果表,只在 payload 里挂一个引用 key。这个改动让状态读写都快了一个数量级。
上下文管理上,核心策略是"滑动窗口加滚动摘要"。对话或者任务执行产生的消息列表,超过一定长度就触发一次摘要压缩:把早期的消息归纳成一段摘要,保留最近的详细消息。这里有一个优先级规则:错误信息、工具返回的原始数据永远排在保留的前列,普通的状态流转信息可以最先被压缩。这样模型在后续节点做决策时,既不会因为上下文太长导致成本失控,也不会丢掉关键的技术细节。
DynamoDB 的 TTL 特性在这种场景下极其顺手。任务执行完毕之后,我会把 TTL 设置为 7 天,过期自动清理。省掉了定时任务扫表删除的麻烦,存储成本也基本可控。
3. 从零搭一个能跑的 ThunderAgent 实例:以日志巡检告警为例
理论讲太多容易飘,这里用一个完整的实操案例把它落地。场景选择的是日志巡检与告警自动化——采集应用错误日志,按错误类型聚合,生成简报并推送。这个场景足够典型:有数据采集、有文本处理、有消息通知、有定时触发,链路清晰,非常适合作为参考模板。
3.1 准备基础环境与项目骨架
我用的环境是 Python 3.11,依赖库包括:boto3(访问 DynamoDB)、httpx(异步 HTTP 请求)、openai(大模型调用,也可以换成其他兼容 API 的 SDK)、apscheduler(定时触发)。项目结构如下:
thunderagent/ ├── agent/ │ ├── core.py # Agent 执行器与状态机 │ ├── parser.py # 任务解析与 schema 校验 │ ├── tools.py # 工具注册与调用封装 │ └── memory.py # 上下文管理与滚动摘要 ├── tasks/ │ └── log_inspection/ # 日志巡检任务定义 ├── config.yaml # 全局配置 └── main.py # 入口,负责调度器启动与任务注册本地没有 DynamoDB 环境的话,可以用本地模拟器(DynamoDB Local)或者直接起一个内存版适配层,接口保持一致。我的习惯是开发环境用本地模拟器,测试通过再切到云端,体验基本无差别。
3.2 定义任务规格与编排结构
日志巡检这个任务,我定义成三个节点:collect_logs(采集日志)、analyze_errors(错误分类与聚合)、notify_report(推送报告)。它们之间存在明确的依赖关系,analyze_errors依赖collect_logs的产物,notify_report依赖analyze_errors的产物。
任务规格用 JSON 来描述,解析层会把自然语言指令映射到这个规格:
{ "task_definition": { "task_type": "log_inspection", "nodes": [ { "node_id": "collect_logs", "tool": "query_logs", "params": {"start_time": "{{TODAY_00:00}}", "end_time": "{{NOW}}", "keyword": "ERROR"} }, { "node_id": "analyze_errors", "tool": "classify_errors", "depends_on": ["collect_logs"] }, { "node_id": "notify_report", "tool": "send_report", "depends_on": ["analyze_errors"] } ] } }{{TODAY_00:00}}这类模板变量会在执行时被解析成实际时间,这样同一个任务定义可以直接复用,不需要每天都改参数。
编排一个多节点任务时,有一个经验值得分享:节点的粒度不要太小,也不要太大。太小的节点(比如"读取一行配置")会让任务图膨胀得没法看,调度的开销比干活本身还大;太大的节点(比如"处理所有错误日志并生成报告")一旦失败没法细化恢复点。我现在的粒度标准是"每个节点应该对应一个可以独立描述职责、独立重试的外部操作或计算单元"。
3.3 核心执行逻辑的编码实现
Agent 执行器的核心循环可以简化成下面的伪代码:
async def execute_task(task_instance): while True: node = get_next_ready_node(task_instance.task_graph) if node is None: break mark_running(task_instance.task_id, node.node_id) try: result = await run_tool(node.tool, node.params) save_node_result(task_instance.task_id, node.node_id, result) update_node_status(task_instance.task_id, node.node_id, "COMPLETED") except ToolExecutionError as e: handle_retry_or_fail(task_instance, node, e) checkpoint(task_instance) summarize_and_notify(task_instance)checkpoint是这里比较关键的一行。每完成一个节点,就把任务状态、已完成节点列表、各节点结果引用写一次存储。这样进程万一中途挂掉,重启之后扫描存储中的任务状态,就能从最近检查点继续执行,而不是从零开始。我在实现里用 version 字段做了乐观锁控制,避免多实例并发处理同一个任务时互相覆盖。具体来说:更新状态前先读 version,更新时携带 version 做条件写,如果版本不一致说明有并发冲突,重新读取后在最新版本上重试。
3.4 工具调用的超时与幂等设计
外部工具调用是整个链路里最容易出问题的地方。写这个项目时,我给所有工具调用加了三层防护:连接超时、整体超时、幂等键。连接超时默认 5 秒,整体超时根据工具类型配置,比如日志查询这种耗时操作可以放宽到 60 秒,报告推送这种应该秒级的操作设置 10 秒。幂等键的作用是防止重试时的副作用:每条任务消息带一个唯一的事件 ID,工具执行时如果发现相同事件 ID 已经处理过,直接返回上次结果而不重复执行。
async def run_tool(tool_name: str, params: dict, idempotency_key: str): # 先检查幂等键是否已存在 cached = await get_idempotent_result(idempotency_key) if cached: return cached # 带超时执行工具 result = await asyncio.wait_for(execute_tool(tool_name, params), timeout=60) await save_idempotent_result(idempotency_key, result) return result这套设计的收益是立竿见影的。日志采集接口偶发抖动时,重试不会再产生重复的聚合结果;模型调用偶尔慢到超时时,任务不会被卡死。所有超时参数都收敛到配置文件里,出问题时不用改代码,直接调参数即可。
3.5 运行结果与效果验证
配置好定时触发(每天早上九点执行一次)之后,把任务丢进去跑了一周,效果稳定。一次典型的执行流程是:调度器拉起任务,解析层把预设的任务描述翻译成结构化定义并落库,采集节点在 20 秒内完成日志查询,聚合节点按错误类型(连接超时、权限异常、数据格式错误等)汇总频次,报告节点把 Top 5 错误明细加趋势说明推送到群里。相比原来人工看日志面板,现在每天早上自动收到一份聚合报告,省下的时间可不止半小时。
这里有一个实际指标值得参考:单次任务的平均执行时长约 80 秒,状态存储读写次数控制在 15 次以内,整体费用可以说是忽略不计。如果在执行过程中故意杀掉进程模拟故障,重启后任务能自动从断点续跑,唯一需要确认的是依赖的外部工具是否幂等——这也是为什么我要单独强调幂等设计的原因。
4. 常见问题与排查技巧:把这半年踩过的坑集中交代一下
ThunderAgent 从能跑起来到跑得稳,中间经历了大量小问题。这些问题单看每个都不大,但组合在一起很消耗耐心。我把它们整理成一个列表,并附上我的处理方式。
4.1 任务"卡死"在等待工具返回
现象是状态停在WAITING_TOOL好几分钟不推进。排查后发现,有些工具依赖的外部 API 并没有按照预期的时间返回,客户端又没有设置整体超时,导致协程一直挂在那里。处理方案就是上面提到的三层防护:连接超时、整体超时、幂等键。另外加了一个看门狗任务,定期扫描长时间停留在WAITING_TOOL状态的记录,做一次超时补偿检查。
4.2 进程重启后任务不能续跑
最初的设计里,任务状态只存在内存中,进程一重启,所有任务状态归零。后来才把状态和节点结果同步到存储层。这里有一个实现细节:每个节点完成之后不仅更新状态,还要保存该节点的结果摘要到结果表。这样续跑时,后续节点可以直接读取前置节点的结果,而不是重新执行前置节点。我在日志巡检场景里特别验证过这个特性,只要外部工具是幂等的,续跑的结果和一次跑完的结果完全一致。
4.3 大模型输出的 JSON 经常变形
不管 Prompt 里强调多少遍"只输出 JSON",实战中总会遇到带 Markdown 代码块标记、多余注释、甚至中英文字符夹杂的问题。我的做法是三层兜底:第一层用正则把代码块标记剥掉;第二层用json.loads解析,失败则尝试修复常见问题(多余逗号、单双引号混用);第三层调用模型重试一次,附带上一次的解析错误信息。同时解析层的 Prompt 也做了调整,明确要求"输出不应包含任何解释说明"。这套兜底机制上线之后,解析失败率显著下降,基本不需要人工介入。
4.4 并发场景下状态互相覆盖
多 Agent 处理不同任务时,如果两个任务恰好共享某个资源配置,或者描述文件被同时修改,可能出现状态互相覆盖。比如一个巡检任务和一个数据同步任务同时触发,都向同一个任务状态表的某一行写入新状态,后写入的会覆盖前一个。解决方案是给状态写入加乐观锁:每次更新时携带旧的版本号,条件更新失败就说明被别的实例改过,需要重新读取再合并。实现不难,但能避免一堆诡异问题。我在代码里专门封装了一个compare_and_swap函数,所有状态更新都走它。
除了上面这些,还有两个小技巧值得一说。一是日志里把task_id、node_id、attempt三个字段固定带上,排查问题时一把梭,不用满文件找上下文。二是配置中心尽量收敛:LLM 的模型名、温度参数、超时时间、重试次数全放配置文件里,不要散落在代码各处。项目迭代过程中,这两个习惯帮我节约了很多排查时间。
| 问题现象 | 根本原因 | 处理方案 |
|---|---|---|
| 任务长时间停在 WAITING_TOOL | 外部 API 慢或无响应 | 全局超时加看门狗扫描 |
| 进程重启后任务状态丢失 | 状态未持久化 | 节点完成即写入存储,支持续跑 |
| 模型输出 JSON 解析失败 | 输出格式不稳定 | 正则清洗、容错解析、重试 |
| 并发更新互相覆盖 | 缺少版本控制 | 乐观锁条件更新 |
| 状态表数据膨胀 | 未设置过期策略 | TTL 自动清理 |
5. 我对这套方案的个人体会与几个后续扩展方向
项目做到现在,我最大的体会是:一个 Agent 框架的复杂度应该集中在"任务边界定义"和"状态可恢复性"上,而不是堆砌各种功能模块。ThunderAgent 能稳定运行的核心原因,不是模型调得多聪明,而是它把每一步的状态都摸得清清楚楚,所有的失败都有明确的处理路径。如果让我重来一遍,我仍然会把 70% 的精力放在状态管理、超时控制和工具抽象这三个基础能力上,模型反而不是最花时间的部分。
对于想参考这个项目做自己方案的读者,有几个建议直接抄作业。工具的注册一定要带 schema 和权限声明,别图省事只保留一个名字;任务脚本要能单节点重跑,否则排查问题的成本会让你怀疑人生;模型的 Prompt 和业务代码要分离,因为你会频繁调 Prompt,但不会想频繁改代码;如果将来要把不同类型的任务都接到这套框架上,建议先把任务定义 schema 建模成一套独立的规范,这样后续扩展是加插件,而不是改框架。
后续我准备往两个方向扩展。一是接入 MCP(模型上下文协议)的思路,把工具注册这块标准化,让 Agent 能更方便地对接更多第三方能力,而不需要每个工具都手写一层适配;二是做一个可视化的任务编排面板,把任务依赖关系和运行状态画出来,毕竟光看 JSON 描述还是不够直观,出了问题能一眼看到是哪个节点卡住了。这两个方向做完之后,ThunderAgent 应该能从"一个能用的框架"变成"一个真正好用的框架"。