ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DeepAgents Harness位置详解:Agent运行时骨架层架构与实战

DeepAgents Harness位置详解:Agent运行时骨架层架构与实战

1. 从“Harness 位置”说起:DeepAgents 里最容易被忽略的骨架层

第一次看到“Harness 位置”这个说法,很多人会以为是某个配置文件里的路径参数,或者某个插件加载目录。实际上在 DeepAgents 这套体系里,Harness 指的是包裹在 Agent 核心推理循环外面的那一层运行时骨架——它决定了工具怎么注册、消息怎么流转、状态怎么持久化、中断怎么恢复、子 Agent 怎么被调度。位置放对了,整个 Agent 跑起来顺滑;位置放错了,就会出现工具调不到、状态丢失、并发下互相踩踏这些让人抓狂的问题。

我接触 DeepAgents 是从 LangChain 生态延伸过来的,当时最直接的感受是:LangGraph 把“图”这个概念引进来之后,Agent 不再是一个黑盒的 while 循环,而是一张可以显式编排的状态图。DeepAgents 在这个基础上又往前走了一步,它把“深度”这件事做成了可组合的结构——主 Agent 可以派生子 Agent,子 Agent 可以再派生,形成一棵任务树。而 Harness 就是承载这棵树的那块“地基”。

这篇文章适合三类人看:第一类是用过 LangChain 基础链、想往 Agent 方向深入的人;第二类是在 LangGraph 上写过图、但对 DeepAgents 的运行时结构还不太清楚的人;第三类是已经在跑 Agent 项目、遇到工具调用不稳定或状态管理混乱、想从架构层面找原因的人。我会把 Harness 的位置这件事拆开讲透,包括它为什么这样设计、放在哪里、和 LangGraph 的节点怎么对应、实操中怎么调、踩过哪些坑。

提示:本文讨论的 Harness 是 Agent 运行时框架层面的概念,不涉及任何网络代理或通道类工具,请放心阅读。

2. DeepAgents 的整体架构与 Harness 的定位

2.1 为什么 Agent 需要一个“骨架层”

先打个比方。如果把 Agent 比作一个在厨房里做菜的厨师,那 LLM 是厨师的脑子,工具是锅碗瓢盆和食材,Prompt 是菜谱。但光有这些还不够——你需要一个厨房本身:灶台在哪里、调料放在哪个架子上、出菜口对着哪里、多个厨师同时干活时谁用哪个灶。Harness 就是这个厨房的布局和运行规则。

没有 Harness 的 Agent 是什么样?就是一个简单的循环:把消息发给模型,模型返回工具调用,执行工具,把结果塞回去,再发给模型。这个循环在单轮、单工具、无状态的场景下能跑,但一旦遇到下面这些情况就崩了:

  • 工具调用需要人工确认才能继续
  • 多个子任务需要并行执行再汇总
  • 执行到一半进程重启,之前的状态全丢了
  • 同一个会话里多个 Agent 共享上下文但又要隔离部分状态
  • 需要在中途插入检查点、做回滚

Harness 要解决的就是这些“循环之外”的问题。它不负责推理,但它负责让推理能够稳定、可恢复、可观测地发生。

2.2 Harness 在 LangGraph 图里的物理位置

从代码结构上看,DeepAgents 构建在 LangGraph 之上。LangGraph 的核心抽象是StateGraph——你定义节点和边,它负责调度。DeepAgents 的 Harness 本质上是一组预置的节点和边,加上一套状态 schema 和中间件机制。

具体来说,一个典型的 DeepAgents 图包含这几类节点:

节点类型职责对应 Harness 层
模型节点调用 LLM,解析输出推理层
工具节点执行工具调用执行层
路由节点决定下一步走向调度层
检查点节点持久化状态持久化层
中断节点暂停等待人工输入交互层

Harness 的“位置”体现在:它不是一个单独的节点,而是贯穿在这些节点之间的编排逻辑和状态契约。你可以把它理解为图的“元层”——它定义了节点之间怎么通信、状态怎么合并、异常怎么传播。

在 LangGraph 的术语里,这对应的是StateGraph的 reducer 函数、checkpointer、interrupt机制,以及 DeepAgents 额外封装的子图调度逻辑。很多人第一次用 DeepAgents 时只关注create_deep_agent这个入口函数,忽略了它背后自动组装的这套 Harness 结构,结果在调试时完全不知道状态在哪里被改了。

2.3 与 LangChain AgentExecutor 的本质区别

如果你用过 LangChain 早期的AgentExecutor,会发现它也是一个循环。但AgentExecutor的循环是命令式的——它在一个 Python while 循环里跑,状态存在内存对象里,中断和恢复能力很弱。LangGraph 把这套东西改成了声明式的图,状态通过 reducer 显式合并,每个节点是纯函数式的转换。

DeepAgents 的 Harness 继承了 LangGraph 的声明式风格,但增加了两层东西:

第一层是子 Agent 调度。主 Agent 可以把一个任务委托给子 Agent,子 Agent 有自己的工具集和 Prompt,执行完后把结果返回给主 Agent。这个委托关系在图上表现为子图嵌套,Harness 负责管理子图的生命周期和状态隔离。

第二层是中间件管道。DeepAgents 允许你在模型调用前后、工具执行前后插入中间件,做日志、做权限校验、做结果后处理。这些中间件的执行顺序和错误处理策略,也是 Harness 的一部分。

理解这两层,你就能明白为什么 DeepAgents 的 Harness 不能简单地等同于 LangGraph 的StateGraph——它是StateGraph加上一套约定和封装,让“深度”这件事变得可管理。

3. Harness 核心机制拆解:状态、工具与中断

3.1 状态 Schema 的设计与 reducer 选择

Harness 最核心的契约是状态 schema。在 LangGraph 里,状态是一个 TypedDict 或 Pydantic 模型,每个字段可以指定一个 reducer 函数来决定“新值怎么和旧值合并”。默认行为是覆盖,但消息列表通常用add_messages这个 reducer 来追加。

DeepAgents 的状态 schema 通常包含这几类字段:

  • messages:对话消息列表,用add_messagesreducer
  • files:虚拟文件系统状态,用于 Agent 读写文件
  • todos:任务清单,子 Agent 调度时用
  • scratchpad:临时草稿区,中间结果存放

这里有个容易踩的坑:reducer 选错了会导致状态爆炸或丢失。比如你把files字段用默认的覆盖 reducer,那子 Agent 写的文件在主 Agent 合并状态时就被覆盖掉了。正确做法是给files也定义一个合并 reducer,通常是字典的浅合并或深合并。

我在实际项目里遇到过一个问题:主 Agent 和子 Agent 同时往messages里写消息,因为add_messages是按消息 ID 去重的,如果两条消息 ID 相同(比如都用了默认生成的 ID),就会丢消息。解决办法是确保每条消息有唯一 ID,或者在子 Agent 返回时重新生成 ID。

注意:状态 schema 一旦确定,中途修改会导致检查点不兼容。如果你用了持久化 checkpointer,改 schema 前先清空旧的检查点数据,否则恢复时会报字段缺失或类型错误。

3.2 工具注册的位置与调用链路

工具在 Harness 里的注册位置决定了它能不能被正确调用。DeepAgents 里工具通常通过tools参数传给create_deep_agent,然后 Harness 会把它们绑定到模型节点上。

调用链路是这样的:

  1. 模型节点调用 LLM,LLM 返回tool_calls
  2. Harness 解析tool_calls,匹配已注册的工具
  3. 匹配到的工具被放入工具节点执行
  4. 执行结果包装成ToolMessage追加到messages
  5. 路由回模型节点,继续下一轮

这里的关键点是工具的名称必须和 LLM 看到的名称一致。如果你用了@tool装饰器,函数名就是工具名;如果你手动构造StructuredTool,要确保name字段和 Prompt 里描述的一致。我见过有人改了函数名但忘了改 Prompt 里的描述,结果模型一直调用一个不存在的工具,Harness 报“tool not found”,排查了半天。

另一个细节是工具的并发执行。LangGraph 默认会并行执行同一轮里的多个工具调用,这要求你的工具函数是线程安全的。如果你的工具会写同一个文件或改同一个全局变量,并发下就会出问题。解决办法是在工具内部加锁,或者配置 Harness 串行执行工具。

3.3 中断与恢复:interrupt 机制的位置

interrupt是 LangGraph 提供的一个机制,允许图在执行到某个节点时暂停,等待外部输入后再继续。DeepAgents 的 Harness 把这个机制用在了几个地方:

  • 工具执行前的人工确认(比如删除文件、发送消息这类敏感操作)
  • 子 Agent 返回结果后的主 Agent 审核
  • 达到某个 token 预算后的暂停

interrupt的位置很关键。它必须放在状态已经被检查点保存之后,否则恢复时找不到断点。在 LangGraph 里,这意味着你要配置一个 checkpointer(比如MemorySaver或SqliteSaver),并且interrupt调用发生在节点函数内部。

恢复时,你调用graph.invoke或graph.stream时传入相同的thread_id,Harness 会从检查点加载状态,从断点继续。这里有个坑:如果你在 interrupt 之后修改了图的结构(比如加了新节点),恢复会失败,因为检查点里存的是旧图的执行位置。

我个人的经验是,开发阶段用MemorySaver快速迭代,上线前换成持久化的 checkpointer(比如基于 SQLite 或 Postgres 的实现)。切换时注意检查点数据的迁移,别直接混用。

4. 实操:从零搭一个带 Harness 的 DeepAgent

4.1 环境准备与依赖安装

先把环境搭起来。我假设你已经有一个 Python 3.10+ 的环境,推荐用虚拟环境隔离依赖。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install langchain langgraph deepagents

如果你要用特定的模型提供商,还需要装对应的 SDK。比如用 OpenAI 的话装langchain-openai,用 Anthropic 的话装langchain-anthropic。DeepAgents 本身不绑定模型,它通过 LangChain 的BaseChatModel接口调用。

环境变量方面,把 API key 配好:

export OPENAI_API_KEY="your-key-here"

提示:不要把 API key 硬编码在代码里,用环境变量或密钥管理服务。这是基本的安全习惯。

4.2 定义工具与状态 Schema

先定义两个简单工具,一个读文件,一个写文件。这里用内存字典模拟文件系统,方便演示。

from langchain_core.tools import tool _fake_fs = {} @tool def read_file(path: str) -> str: """读取指定路径的文件内容。""" return _fake_fs.get(path, "文件不存在") @tool def write_file(path: str, content: str) -> str: """将内容写入指定路径的文件。""" _fake_fs[path] = content return f"已写入 {path},共 {len(content)} 字符"

状态 schema 用 TypedDict 定义,messages用add_messagesreducer:

from typing import Annotated from typing_extensions import TypedDict from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] files: dict todos: list

files和todos用默认覆盖 reducer 就够了,因为我们只在主 Agent 层面维护它们,子 Agent 通过工具读写。

4.3 组装 DeepAgent 与 Harness 配置

用create_deep_agent创建 Agent,传入工具和状态 schema:

from deepagents import create_deep_agent from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() agent = create_deep_agent( tools=[read_file, write_file], state_schema=AgentState, checkpointer=checkpointer, )

这里的checkpointer就是 Harness 持久化层的配置。MemorySaver适合开发调试,进程重启后状态丢失。生产环境换成SqliteSaver或PostgresSaver。

调用时传入thread_id来标识会话:

config = {"configurable": {"thread_id": "demo-001"}} result = agent.invoke( {"messages": [{"role": "user", "content": "帮我在 notes.txt 里写一段关于 Harness 的笔记"}]}, config=config, )

Harness 会自动处理工具调用循环,直到模型不再请求工具为止。

4.4 验证 Harness 行为:状态流转与检查点

跑完之后,你可以检查状态:

state = agent.get_state(config) print(state.values["messages"]) print(state.values["files"])

如果一切正常,你会看到messages里有用户消息、AI 的工具调用消息、工具返回消息、以及最终的 AI 回复。files里会有notes.txt的内容。

再验证检查点:用同一个thread_id再发一条消息,Agent 应该能记住之前的上下文。

result2 = agent.invoke( {"messages": [{"role": "user", "content": "刚才写的文件里有多少字?"}]}, config=config, )

如果 Agent 能回答出字数,说明检查点生效了,Harness 正确地从持久化层恢复了状态。

注意:MemorySaver只在当前进程内有效。如果你在 Jupyter 里跑,重启 kernel 后状态就没了。测试持久化要用SqliteSaver。

5. 常见问题与排查技巧实录

5.1 工具调用失败:从报错信息定位 Harness 层

工具调用失败是最常见的问题,报错信息通常分几类:

报错信息可能原因排查方向
tool not found工具名不匹配检查@tool函数名和 Prompt 描述
invalid arguments参数 schema 不对检查类型注解和 Pydantic 模型
tool execution error工具内部异常看工具函数的 traceback
max iterations reached循环次数超限检查模型是否陷入死循环

我遇到最多的是参数 schema 问题。比如工具定义时用了path: str,但模型返回的是{"file_path": "..."},Harness 匹配不上就报错。解决办法是在工具描述里写清楚参数名,或者用 Pydantic 模型显式定义参数结构。

另一个隐蔽的问题是工具返回值的序列化。如果你的工具返回一个自定义对象,Harness 在包装成ToolMessage时会尝试 JSON 序列化,失败就报错。工具返回值尽量用字符串、字典、列表这些基础类型。

5.2 状态丢失与并发冲突的处理

状态丢失通常发生在两个场景:一是 checkpointer 没配好,二是并发写入冲突。

checkpointer 的问题好排查:确认config里传了thread_id,确认 checkpointer 实例被正确传给create_deep_agent。如果用的是SqliteSaver,确认数据库文件路径可写。

并发冲突更麻烦。LangGraph 默认会并行执行同一轮里的多个工具调用,如果两个工具同时改files字典,后写的会覆盖先写的。解决办法有两个:

  • 在工具内部用锁保护共享状态
  • 配置 Harness 串行执行工具(牺牲性能换正确性)

我一般推荐第一种,因为串行执行会让多工具场景变慢很多。用threading.Lock包住对共享状态的读写就行。

5.3 子 Agent 调度中的位置陷阱

子 Agent 调度是 DeepAgents 的特色,但也是坑最多的地方。核心问题是状态隔离:子 Agent 有自己的messages和files,返回给主 Agent 时只返回摘要,不返回完整状态。

如果你希望子 Agent 写的文件能被主 Agent 看到,需要在子 Agent 返回时显式把文件内容传回去,或者让子 Agent 通过工具写主 Agent 的共享文件系统。默认情况下,子 Agent 的状态是隔离的。

另一个陷阱是子 Agent 的 interrupt。如果子 Agent 触发了 interrupt,主 Agent 的图会暂停在子图的位置。恢复时要用相同的thread_id,Harness 会从子图的断点继续。但如果你在子 Agent 里用了不同的 checkpointer,恢复就会失败。

我的建议是:子 Agent 和主 Agent 共用同一个 checkpointer,thread_id用主 Agent 的,子图的状态会嵌套在主图的检查点里。

5.4 性能调优:减少不必要的 Harness 开销

Harness 本身有开销,主要体现在状态序列化和检查点写入上。如果你的 Agent 跑得慢,可以从这几个方向优化:

  • 减少检查点频率:不是每个节点都需要检查点,只在关键节点配置
  • 精简状态 schema:不要把大对象塞进状态,用引用或 ID 代替
  • 工具结果裁剪:工具返回的长文本先截断再放进ToolMessage
  • 并行工具调用:确认工具是线程安全的,让 Harness 并行执行

我实测下来,把检查点从每节点一次改成只在工具调用后一次,吞吐量能提升 30% 左右。当然这取决于你的具体场景,状态越复杂提升越明显。

6. 关于 Harness 位置的一些个人体会

回到“Harness 位置”这个标题。我现在的理解是,Harness 的位置不是一个静态的坐标,而是一个动态的职责边界。它在模型和工具之间、在主 Agent 和子 Agent 之间、在内存和持久化之间,扮演的是协调者和契约维护者的角色。

你把它放对了,Agent 就像一台调校好的机器,工具调用、状态流转、中断恢复都顺理成章。你把它放错了,或者根本没意识到它的存在,就会陷入“为什么工具调不到”“为什么状态丢了”“为什么恢复不了”的泥潭。

最后分享一个小技巧:调试 Harness 问题时,打开 LangGraph 的 tracing,把每一步的节点输入输出都打出来。很多时候问题不在你的代码逻辑,而在状态合并的那一瞬间。看到状态怎么变的,问题就解决了一半。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进