资讯详情

DeepSeek Harness:可回放、全插件化的Agent工程框架

📅 2026/10/22 11:46:29 | 华诺云谱 👁 阅读
DeepSeek Harness:可回放、全插件化的Agent工程框架
1. 为什么是 Harness核心架构与边界设计先交代一下背景我最近半年一直在做基于 DeepSeek 模型接口的 Agent 应用从最早的“一个 while 循环套模型调用”的 Demo一路演进到一个需要支撑多工具、多用户、多会话并发的小型平台系统。这个过程中踩了不少坑——最痛的不是模型本身不够聪明而是应用层的胶水代码越堆越乱工具互相影响出了错没法复现。所以才有了那个被我命名为 DeepSeek Harness 的项目而这篇文章就是对这个项目做一次工程层面的完整解剖。1.1 Harness 在 Agent 语境里到底指什么很多人第一次听到“Harness”这个词会愣一下觉得不如用 Framework、Runtime 这种熟词。但实际上 Harness 在工程语境里有个很精准的指向它是一套“把受控对象约束在可预期轨道上的框架”的意思。测试领域里叫测试夹具把被测对象固定住在 Agent 领域里Harness 的核心不是替你写好业务逻辑而是把模型推理、工具调用、上下文维护、日志记录这几件极其容易失控的事情约束进一套统一的控制循环里。所以这个项目从一开始就定义了两条不可妥协的原则第一业务能力一律外置成插件内核不写死任何一个具体工具第二所有发生在循环里的关键事件必须落盘成结构化日志而且这份日志得能支撑事后完整回放。这两条原则直接决定了整个项目的架构走向。我见过太多 Agent 项目死在半路上原因倒不是模型能力不够而是工程上没法迭代——改一个工具的入参格式要动三处代码跑一次会话出了问题对着终端日志根本看不出当时模型拿到的是什么上下文。Harness 这个设计就是为了把这些工程问题前置到框架层解决。1.2 整体模块划分内核、桥接层、会话管理层DeepSeek Harness 的整体模块划分其实很传统但每层职责砍得非常清晰这也是我给你建议时最想强调的一点不要把 Agent 框架做成一个大泥球。调度内核跑着统一控制循环负责“取事件、派发给模型、收集插件结果、决策下一步”这个循环本身。它不关心具体模型是哪家也不关心工具做的是什么。插件桥接层所有插件通过统一协议接入内核只认协议不认实现。这个桥接层解决的是“插件各自为政”的问题。会话管理层维护一次会话的上下文窗口、会话状态快照、用户身份与隔离信息。它和模型调用解耦回放时甚至可以把模型换成 mock。日志与回放引擎所有关键事件以追加写方式落盘并提供一套回放器把日志重新“演”一遍支持在回放过程中用记录替换模型输出。这个分层的好处是替换模型不需要动插件代码新插件不需要动内核代码排查问题不需要翻散落各处的 print。模块之间只有接口依赖没有实现依赖。1.3 为什么这个设计比单体 Agent 更抗迭代对比一下两种方案。单体 Agent 通常把“工具列表、模型选择、上下文拼装、会话历史”全部写在一个类里demo 阶段确实爽改什么都快。但一旦工具数量超过五个、会话持续多轮、需要接入多人使用时单体类就开始变成维护者的噩梦加一个工具要小心翼翼因为工具 A 可能改了共享状态影响工具 B 的行为上下文拼装逻辑散落在各个回调里日志只能用 print 凑合。而 Harness 架构把可变点全部推向边界。内核只维护一个极稳定的状态机插件是热插拔的会话是独立可序列化的。我在把第一个真实工具一个内部知识库查询插件接入 Harness 时全程只写了插件文件本身内核代码一行没改。这种“框架稳定、能力外置”的取舍就是 Harness 和其他 Agent 脚手架最大的区别。提示如果你现在维护的 Agent 项目已经开始出现“改一处崩两处”的征兆尽快把业务能力往协议后面抽别等代码到了几千行再重构。2. 全插件化从接口协议到注册中心的工程细节插件化是 Agent 框架最容易被做“虚”的部分。很多框架说自己插件化其实就是提供了一堆抽象基类子类继承后挂到一个列表里。这种写法应付小规模没问题但真正的工程化插件体系至少要解决三件事插件如何声明自己的能力、插件之间如何隔离、插件如何被动态装载和卸载。2.1 插件协议设计协议优先于继承我在 Harness 里做插件协议时刻意没有用抽象基类ABC而是用了鸭子类型的 Protocol。这不是炫技而是有实际考量抽象基类要求所有插件必须继承同一个类这在 Python 里意味着单继承限制和明显的类型耦合而 Protocol 只约束“你实现了这几个方法就行”插件实现者完全可以用自己的类体系只要行为适配协议就能接入。以模型接口的适配器为例所有插件需要满足这样一套基础协议from typing import Any, AsyncIterator, Protocol, Dict, List class AgentPlugin(Protocol): 定义 Harness 插件必须实现的生命周期方法 async def initialize(self, context: dict) - None: 插件初始化context 中注入配置和共享资源 def capabilities(self) - List[str]: 返回插件能处理的事件类型列表 async def execute(self, event: dict, runtime: dict) - dict: 执行插件核心逻辑返回统一结构的结果 async def teardown(self) - None: 释放插件占用的资源这套协议的关键心法在于capabilities方法。内核在派发事件时不是把事件广播给所有插件而是先读插件声明的能力标签只把事件分发给对口的插件。这个机制避免了插件之间互相干扰也为后续做插件级并发控制打好了基础。2.2 注册与发现机制三种方式谈清楚插件不是通过 import 硬编码进系统的Harness 支持三种注册方式按应用场景灵活选择装饰器注册在插件类定义处加harness_plugin框架在启动扫描指定包时自动收集。这是开发期最顺手的方式代码即注册。配置清单注册通过 YAML/JSON 配置文件显式列出插件类路径和参数。生产环境推荐这种方式因为运维可以通过配置中心动态调整插件组合不用重新部署代码。目录扫描注册指定一个插件目录框架扫描目录下所有符合命名约定的模块并尝试装载。适合做开放生态但要注意模块安全校验。我实际跑下来开发环境用装饰器生产环境强烈建议切到配置清单。原因很实在装饰器声明散落在代码里你没法一眼看清当前环境到底加载了哪些插件配置文件则一目了然而且方便做多环境差异化配置。2.3 插件失败隔离与资源治理插件一旦多了最大的工程风险是“一颗老鼠屎坏了一锅汤”。一个插件超时阻塞可能导致整个控制循环卡死一个插件抛异常可能污染会话上下文。Harness 在这块做了三道防线第一道是超时控制。每个插件执行外面包了一层asyncio.wait_for超时后直接取消任务并返回超时错误事件而不是让它无限期占用循环。第二道是并发信号量。插件可以声明自己的最大并发数比如数据库查询插件并发上限设为 2避免突发的多会话同时调用把数据库打崩。第三道是错误隔离。插件的异常会被捕获并转换成标准化错误事件控制循环根据错误事件决定是重试、换策略还是终止会话。这三道防线听着简单但你如果自己实现过就会发现关键是内核对“插件失败”这件事有统一的理解失败只是事件流里的一个普通事件而不是打断循环的例外。这个思维转换很重要它让你能用状态机的视角去看整个 Agent 运行过程。3. 可回放会话日志让 LLM 应用可调试、可测试、可审计说实话可回放会话日志才是这个项目里我觉得价值最大、也最难做好的部分。LLM 应用有个天然痛点模型输出是不确定的。同一个 prompt 两次调用结果大概率不同。这就导致你很难定位“这个 bug 是模型抽风还是我代码写错了”。可回放日志要解决的就是把一次会话的每一次关键决策记录下来事后能精确重演当时发生了什么。3.1 事件溯源思路把日志当数据库写传统日志是“打印给人看”的而 Harness 的会话日志是“结构化成事件流”的。每条日志记录不是一个字符串而是一个带类型、时间戳、会话 ID、载荷的 JSON 对象。一次完整会话产生的日志本质上是一串有序事件用户输入事件、模型请求事件、模型响应事件、插件调用事件、插件结果事件、内部状态变更事件。这里我借用了事件溯源Event Sourcing的思想会话的当前状态不是直接保存的而是可以通过重放事件流还原出来的。这有一个特别大的好处——你不必在日志里保存一份容易过期的“当前状态快照”只要事件流完整任何时刻的状态都能重建。设计上每一轮循环产生的事件组是这样一串结构缩略示例{ session_id: 6f3c9a1e, event_seq: 42, event_type: plugin_exec, ts: 2024-12-08T15:04:05.123Z, plugin_name: search_kb, input: {query: harness 插件协议, top_k: 5}, output: {items: [Plugin protocol ...]}, duration_ms: 231, status: ok }3.2 录制什么不止是模型输入输出刚开始做时我只记模型请求和响应日志量小了但回放时发现根本不够——工具的中间输出、上下文窗口的截断行为、重试策略的触发条件全都没有记录。后来我重新梳理了一份“必须录制”清单模型请求快照完整 prompt、参数temperature、max_tokens 等、模型名与版本。模型响应快照完整输出、token 用量、延迟。插件调用快照入参、出参、异常堆栈、耗时。上下文生命周期每轮上下文追加了什么、因为超长被裁剪了什么。决策轨迹路由逻辑选了哪个插件、重试策略怎么触发的。这份清单听着条目多但实现起来并不复杂。核心就是内核提供了两个埋点一个在模型调用前后一个在插件调用前后。所有事件走同一套序列化管线统一落盘。3.3 回放引擎用记录替代模型输出实现确定性复现录制只是存储问题回放才是技术难点。Harness 的回放引擎有两种模式第一种是模拟回放模式。回放器截断真实的模型调用把日志里记录的模型响应直接塞给控制循环插件仍然真实执行。这种模式的用途是调试工具逻辑如果上次会话里模型说“调用 search_kb 搜 XX”回放时就真的再调用一次 search_kb通过对比两次插件执行结果定位到底是不是插件代码改出了回归问题。第二种是验证回放模式。保持模型真实调用但把插件结果替换成日志里的旧值。这种模式用来做模型回归评测——在插件输出固定的条件下看当前模型版本是否还会做出和以前一样的决策用来验证 prompt 修改或模型切换是否破坏了原有能力。这两种模式加在一起让“跑一次真实会话”变成“可以在不同约束条件下反复演练的实验”。我现在每次改插件代码第一件事不是跑新的 demo 对话而是把历史会话日志回放一遍确认没有破坏任何旧场景。这已经变成了我的工程习惯。4. 最小可运行框架的实现路径理论讲再多不如直接看代码。这一节我带你从零搭一个最小可运行的 Harness 雏形。我刻意把代码量控制在两百行以内但麻雀虽小五脏俱全。4.1 控制循环Agent 的心脏控制循环是 Harness 的最小可运行心脏。它的职责非常清晰消费会话中的事件队列调用模型获取决策根据决策派发插件执行把结果写回上下文然后循环往复直到模型产出终止信号。import asyncio from datetime import datetime, timezone class MiniHarness: def __init__(self, model, registry, recorder): self.model model self.registry registry self.recorder recorder async def run(self, user_input: str, session: dict): context session.get(context, []) context.append({role: user, content: user_input}) for _ in range(session.get(max_steps, 20)): events [] # 1. 模型决策录制模型请求响应 self.recorder.record(model_request, {prompt: context}) decision await self.model.decide(context) self.recorder.record(model_response, {decision: decision}) # 2. 模型认为可以结束退出循环 if decision.get(action) finish: context.append({role: assistant, content: decision.get(output, )}) break # 3. 根据决策派发插件执行 plugin self.registry.get(decision[action]) if plugin is None: self.recorder.record(unknown_action, {action: decision[action]}) break self.recorder.record(plugin_start, {action: decision[action]}) result await plugin.execute(context, decision.get(args, {})) self.recorder.record(plugin_end, {result: result}) # 4. 把工具结果注入上下文 context.append({role: tool, name: decision[action], content: str(result)}) return context[-1][content]这段代码虽然简化到极致但已经能看出来 Harness 的核心约束循环、录制、路由三件事被隔离得很清楚。你往里面加日志、加重试、加超时都是在固定位置做加法而不是到处乱插。4.2 日志录制器把事件流稳定落盘录制器不能只写文本必须写成结构化事件。我在最小实现里用了一个非常简单的 JSONL 写入器import json, os class JsonlRecorder: def __init__(self, path, session_id): self.session_id session_id self.seq 0 self.buffer [] self.fh open(path, a, encodingutf-8) def record(self, etype, payload): self.seq 1 event { session_id: self.session_id, event_seq: self.seq, event_type: etype, ts: datetime.now(timezone.utc).isoformat(), payload: payload, } self.buffer.append(json.dumps(event, ensure_asciiFalse)) if len(self.buffer) 10: self.flush() def flush(self): if self.buffer: self.fh.write(\n.join(self.buffer) \n) self.buffer.clear() def close(self): self.flush() self.fh.close()注意这里的缓冲批量写。在实际生产里每秒可能会产生几十条事件如果每条事件都直接write加flush磁盘 IO 会拖慢整个循环。批量化之后IO 压力小很多而且进程崩溃时最多丢一个批次通常也就十条事件对调试影响很小。4.3 回放入口最简可用的重放器回放器的最小实现也只需要做一件事逐行读取 JSONL把model_response事件里的 decision 塞回给一个替身模型。替身模型不真正访问模型接口只是根据事件序列“照着演”。class ReplayModel: def __init__(self, log_path): self.recorded {} idx 0 with open(log_path, encodingutf-8) as f: for line in f: evt json.loads(line) if evt[event_type] model_response: self.recorded[idx] evt[payload][decision] idx 1 self.cursor 0 async def decide(self, context): if self.cursor not in self.recorded: raise RuntimeError(回放数据不足日志不完整) decision self.recorded[self.cursor] self.cursor 1 return decision把ReplayModel传给MiniHarness跑出来的轨迹就和原会话完全一致——前提是插件逻辑没有改变。如果插件逻辑变了回放会在某一步产生不同的插件输出这时你就能在对比中精确看到是哪一次插件行为变化导致了后续决策链的连锁反应。注意回放模式最常用的一个场景是把旧会话里出现过的“坏决策”拿出来配上修复后的插件代码重跑一遍验证它现在是否能被正确纠正。这类回归测试比人工对话验证高效太多。5. 常见问题与排查技巧实录任何框架在真实使用中都会遇到一堆文档里没有的怪问题Harness 也不例外。这一节我把实际踩过并且值得分享的坑整理出来按问题现象、原因、解决路径的格式列一下。5.1 回放结果与原始会话不一致这个问题在我前几次回放时几乎必现。明明用的是同一份日志回放出来的决策轨迹和原始会话却对不上。排查下来原因很典型插件是不确定的或者模型请求日志没有记录全。具体来说如果你的插件里有一个读时间的操作、一个读环境变量的操作或者一个访问外部接口获取“当前值”的操作那么回放时即便决策相同插件输出也会不同进而影响后续上下文。这类问题不能靠“让插件完全确定”解决——真实业务里插件就是会读外部状态。我的解决方案是回放时把插件结果也分成两类一类是“关键结果”强制回归用旧值回放一类是“实时结果”允许变化真实执行。这样在断言决策链时就不会被外部状态波动干扰。这个思路对应到模式就是上面说的验证回放和模拟回放之别。5.2 插件热更新后旧日志回放报错插件版本升级后老日志里记录的插件入参格式可能已经不兼容了。比如旧版插件入参是query新版改成了search_text回放旧日志就会 KeyError。这个问题没有银弹只能在插件侧做兼容层。我在插件基类里约定了一个adapt_legacy_args钩子每个插件可以声明老版本参数如何映射到新版本。回放器加载日志时如果发现版本号不匹配就自动调用这个钩子。这个机制让我的回放测试用例可以跨多个版本长期保存不会因为升级就全部作废。5.3 日志文件膨胀得太快一次完整的长会话可能产生数千条事件如果每条都带完整 payload单个会话日志能到几 MB。多个用户、多个会话累积下来磁盘压力很大。我的做法是分级存储近期日志用高保真格式存在热目录便于调试超过七天的日志做一次缩略处理把插件 payload 裁剪成摘要字段只保留事件轮廓。回放器碰到缩略日志时会自动从详细度更高的备份中补全。这里也可以分享一个小技巧录制器落盘时把字符串字段统一做一次json.dumps去空白能显著降低日志体积代价仅是日志不再适合人肉阅读但对机器回放毫无影响。5.4 排查流程沉淀成清单经过几个月的使用我把排查流程固定成了下面这一套操作顺序。无论问题是模型决策错误还是插件异常我都按这个流程走先看事件时间线把所有事件的event_type和时间戳列出来确认问题发生在哪个环节然后看模型决策序列决策是顺序记录在事件流里的对照业务预期逐条检查能快速圈定是第几步出的问题再看插件调用详情明确决策正确但工具执行失败还是工具结果正常但决策逻辑有问题最后做差分回放修复后重新回放同一份日志对比修复前后的分叉点。这套流程最大的价值是省掉了肉眼盯打印日志的时间。以前出了 bug 要看半天终端输出现在直接对着事件结构做查询效率和准确率完全不是一个量级。6. 从工程实践到下一步扩展Harness 这个形态做得越久我越觉得 Agent 框架的趋势是从“模型为中心的套壳”走向“可观测、可回放、可评测的工程底座”。全插件化解决的是组织与扩展问题可回放日志解决的是信任与迭代问题——这两个问题恰恰是 LLM 应用从实验品走向产品的两道大坎。6.1 比较Harness 与常见替代方案的取舍如果你也在评估要不要搭建类似的东西我帮你理清一下 Harness 方案和其他常见做法的边界。市面上现成的 Agent 编排框架很多功能往往更丰富但引进大而全的框架通常意味着你要接受它的协议约束、数据格式和部署方式。Harness 这种自研轻量方案则相反——它只强制你遵守“协议”和“日志”这两件事其余全部开放。这个取舍在团队小、场景独特时尤其值得。我的真实体会是与其花大量时间学习一个大框架的各种高级特性不如用 20% 的精力把 Harness 的最小骨架搭好剩下 80% 的精力放在业务插件的打磨上。毕竟 Agent 项目最终的竞争力来自业务能力质量而不是框架多炫。6.2 可扩展方向可视化时间线、会话差分、自动回测Harness 目前的回放还停留在命令行对比层面但这套事件流的延伸空间其实很大。我下一步准备做的是可视化时间线把一次会话渲染成一张按时间展开的泳道图左边是模型决策右边是插件调用中间用箭头画出上下文流动。调试者一眼就能看到“模型在这里决定调用工具但工具结果让模型改变主意”的完整链路。另一个方向是自动化回测——把历史会话整理成回归语料库每次修改框架或插件就跑一遍全量回放把回放分叉点汇总成报告。我已经验证了这个流程的可行性效果非常理想唯一要花功夫的是积累足够多的高质量会话样本。6.3 最后分享一点个人体会这套框架从最初的八百行实验代码演进到现在的稳定形态中间推倒重来了两次。第一次重来是发现插件之间互相污染状态第二次是发现日志记录不够细导致回放残缺。现在回头看这两个问题其实在设计阶段就能规避——只要把“能力外置”和“事件溯源”这两条主线从一开始就焊死在架构里。如果你正在做类似的 Agent 项目我的核心建议是内核代码一定要极度克制宁可把功能放在插件里让它们“野蛮生长”也不要让内核变成什么都干的管家日志事件一定要从第一天就结构化千万不要觉得“等上线后再补日志”来得及——等到你需要回放一个三天前的会话去定位线上问题时基本就晚了。文章写到这里我不打算再做多余的总结。用一句话收束吧Agent 的智能可以交给模型但 Agent 的可靠性必须由工程来兜底Harness 的设计哲学就是为此服务的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑