详细讲解使用LangChain新框架构建多步骤推理系统的实现方法,附完整代码示例,高质量实践教程。
LangChain 的 Deep Agents 提供了一种构建结构化多智能体系统的新方式,使系统能够进行规划、委派,并跨多个步骤推理。
它内置了规划、用于保存上下文的文件系统,以及子智能体生成能力。但要把这个智能体连接到实时前端,并实时展示幕后正在发生的事情,仍然出人意料地困难。
今天,我们将使用 Tavily 构建一个由 Deep Agents 驱动的研究助手,并通过 CopilotKit 将其连接到实时 Next.js UI,使智能体执行的每一步都能实时流式传输到前端。
你将了解系统架构、关键模式、状态如何在 UI ↔ 智能体之间流动,以及如何从零开始逐步构建它。
总而言之,我们将详细介绍以下主题。
什么是 Deep Agents?
我们要构建什么?
构建后端(FastAPI + Deep Agents + AG-UI)
数据流(前端 ↔ 智能体)
如果你想自行探索,可以查看 GitHub 仓库、已部署的链接和官方文档。
如今,大多数智能体都只是“循环中的 LLM + 工具”。这种方式确实有效,但往往比较浅层:没有明确的计划,长周期任务执行能力较弱,而且随着运行时间变长,状态会变得混乱。
Claude Code、Deep Research 和 Manus 等热门智能体通过遵循一种共同模式解决了这个问题:先制定计划,将工作上下文外部化(通常借助文件或 shell),再将相互独立的工作委派给子智能体。
Deep Agents 将这些基础能力封装成了一个可复用的智能体运行时。
你不需要从零开始设计自己的智能体循环,只需调用 create_deep_agent(...),即可获得一个预先连接好的执行图,它已经知道如何在多个步骤中进行规划、委派和状态管理。
从实际实现来看,通过 create_deep_agent 创建的 Deep Agent 只是一个 LangGraph 图,并不存在独立的运行时或隐藏的编排层。
Deep Agents 中的“上下文管理”也非常实用——它们会将大型工具负载卸载到文件系统中,只有当 token 使用量接近模型的上下文窗口上限时,才会退而采用摘要。你可以阅读 LangChain 的《Context Management for Deep Agents》博客了解更多信息。
从概念上讲,执行流程如下:
User goal
↓
Deep Agent (LangGraph StateGraph)
├─ Plan: write_todos → updates "todos" in state
├─ Delegate: task(...) → runs a subagent with its own tool loop
├─ Context: ls/read_file/write_file/edit_file → persists working notes/artifacts
↓
Final answer
这样一来,无需自行设计计划格式、记忆层或委派协议,你就能获得一套可用的“规划 → 执行工作 → 存储中间产物 → 继续执行”结构。
你可以查看官方文档。
Deep Agents 会将关键部分放入显式状态中(例如 todos、files 和 messages),使运行过程更容易检查。也正是这种显式状态,让 CopilotKit 集成成为可能。
CopilotKit 是一种前端运行时,通过实时流式传输智能体事件和状态更新,使 UI 状态与智能体执行保持同步(底层使用 AG-UI)。
正是这个中间件(CopilotKitMiddleware),让前端能够在智能体运行期间与其步调完全一致。你可以在 docs.copilotkit.ai/langgraph/deep-agents 阅读相关文档。
agent = create_deep_agent(
model="openai:gpt-4o",
tools=[get_weather],
middleware=[CopilotKitMiddleware()], # for frontend tools and context
system_prompt="You are a helpful research assistant."
)
下面是我们稍后会使用的核心组件:
1)规划工具(通过 Deep Agents 内置)——内置规划和待办事项行为,使智能体无需你编写单独的规划工具,就能将工作流拆分为多个步骤。
# Conceptual example (not required in codebase)
@tool
def todo_write(tasks: List[str]) -> str:
formatted = "\n".join([f"- {task}" for task in tasks])
return f"Todo list created:\n{formatted}"
2)子智能体——允许主智能体将聚焦的任务委派到相互隔离的执行循环中。每个子智能体都有自己的提示词、工具和上下文。
subagents = [
{
"name": "job-search-agent",
"description": "Finds relevant jobs and outputs structured job candidates.",
"system_prompt": JOB_SEARCH_PROMPT,
"tools": [internet_search],
}
]
3)工具——智能体正是通过工具实际执行操作。在这里,finalize() 用于表示任务已完成。
@tool
def finalize() -> dict:
"""Signal that the agent is done."""
return {"status": "done"}
如果你想知道 create_deep_agent() 究竟如何将规划、文件和子智能体能力注入普通的 LangGraph 智能体,答案就是中间件。
每项功能都由一个独立的中间件实现。默认情况下,会附加以下三个中间件:
待办事项列表中间件——添加 write_todos 工具以及相关指令,促使智能体在执行多步骤任务时明确制定计划,并持续更新实时待办事项列表。
待办事项列表中间件——添加 write_todos 工具以及相关指令,促使智能体在执行多步骤任务时明确制定计划,并持续更新实时待办事项列表。
文件系统中间件——添加文件工具(ls、read_file、write_file、edit_file),使智能体可以将笔记和产物外部化,而不是把所有内容都塞进聊天历史记录。
文件系统中间件——添加文件工具(ls、read_file、write_file、edit_file),使智能体可以将笔记和产物外部化,而不是把所有内容都塞进聊天历史记录。
子智能体中间件——添加 task 工具,使主智能体能够把工作委派给拥有隔离上下文以及独立提示词和工具的子智能体。
子智能体中间件——添加 task 工具,使主智能体能够把工作委派给拥有隔离上下文以及独立提示词和工具的子智能体。
这正是 Deep Agents 在不引入新运行时的情况下,仍能给人一种“预先连接完毕”之感的原因。如果你想深入了解,中间件文档中展示了具体的实现细节。
让我们创建一个具备以下能力的智能体:
接收用户提出的研究问题
使用 Deep Agents 进行多步骤规划并编排子智能体
使用 Tavily 搜索 Web
通过文件系统中间件写入中间研究产物
通过 CopilotKit(AG-UI)将工具结果流式传回 UI
整个界面是一个双面板应用:左侧是 CopilotKit 聊天 UI,右侧是实时工作区,在智能体工作时展示它的计划、生成的文件和信息来源。
下面是即将发生的请求 → 响应流程的简化版本:
[User asks research question]
↓
Next.js Frontend (CopilotChat + Workspace)
↓
CopilotKit Runtime → LangGraphHttpAgent
↓
Python Backend (FastAPI + AG-UI)
↓
Deep Agent (research_assistant)
├── write_todos (planning, built-in)
├── write_file (filesystem, built-in)
├── read_file (filesystem, built-in)
└── research(query)
└── internal Deep Agent [thread-isolated]
└── internet_search (Tavily)
在构建这个智能体的过程中,我们将看到这些概念如何实际运作。
让我们先构建前端部分。目录结构如下所示。
src 目录包含 Next.js 前端,其中包括 UI、共享组件,以及用于智能体通信的 CopilotKit API 路由(/api/copilotkit)。
.
├── src/ ← Next.js frontend
│ ├── app/
│ │ ├── page.tsx
│ │ ├── layout.tsx ← CopilotKit provider
│ │ └── api/
│ │ └── copilotkit/route.ts ← CopilotKit AG-UI runtime
│ ├── components/
│ │ ├── FileViewerModal.tsx ← Markdown file viewer
│ │ ├── WorkSpace.tsx ← Research progress display
│ │ └── ToolCard.tsx ← Tool call visualizer
├── lib/
│ └── types.ts
├── package.json
├── next.config.ts
└── README.md
如果你还没有前端,可以使用 TypeScript 创建一个新的 Next.js 应用。
// creates a nextjs app
npx create-next-app@latest .
安装所需的 CopilotKit 软件包。
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime
@copilotkit/react-core 提供核心 React Hooks 和上下文,用于将 UI 连接到兼容 AG-UI 的智能体后端。
@copilotkit/react-core 提供核心 React Hooks 和上下文,用于将 UI 连接到兼容 AG-UI 的智能体后端。
@copilotkit/react-ui 提供 <CopilotChat /> 等开箱即用的 UI 组件,帮助你快速构建 AI 聊天或助手界面。
@copilotkit/react-ui 提供 <CopilotChat /> 等开箱即用的 UI 组件,帮助你快速构建 AI 聊天或助手界面。
@copilotkit/runtime 是服务端运行时,它会公开一个 API 端点,并在前端与兼容 AG-UI 的后端(例如 LangGraph HTTP 智能体)之间建立桥接。
@copilotkit/runtime 是服务端运行时,它暴露一个 API 端点,在前端和 AG-UI 兼容后端(比如 LangGraph HTTP AI 智能体)之间建立桥接。
<CopilotKit> 组件必须包装应用中所有 Copilot 感知部分。大多数情况下,最好把它放在整个应用外层,比如在 layout.tsx 中。
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
export const metadata: Metadata = {
title: "Deep Research Assistant | CopilotKit Deep Agents Demo",
description: "A research assistant powered by Deep Agents and CopilotKit - demonstrating planning, memory, subagents, and generative UI",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">
<CopilotKit runtimeUrl="/api/copilotkit" agent="research_assistant">
{children}
</CopilotKit>
</body>
</html>
);
}
这里 runtimeUrl="/api/copilotkit" 指向 CopilotKit 用来与 AI 智能体后端通信的 Next.js API 路由。
这样每个页面都被包装在这个上下文中,UI 组件就知道要调用哪个 AI 智能体,以及向哪里发送请求。
这个 Next.js API 路由充当浏览器和深度 AI 智能体之间的薄代理。它的职责是:
无需让前端直接与 FastAPI AI 智能体通信,所有请求都走一个单一端点 /api/copilotkit。
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest } from "next/server";
// Empty adapter since the LLM is handled by the remote agent
const serviceAdapter = new ExperimentalEmptyAdapter();
// Configure CopilotKit runtime with the Deep Agents backend
const runtime = new CopilotRuntime({
agents: {
research_assistant: new LangGraphHttpAgent({
url: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123",
}),
},
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
以下是上述代码的简单说明:
上述代码注册了 research_assistant AI 智能体。
LangGraphHttpAgent:定义一个远程 LangGraph AI 智能体端点。它指向运行在 FastAPI 上的深度 AI 智能体后端。
ExperimentalEmptyAdapter:简单的空操作适配器,用于 AI 智能体后端自己处理 LLM 调用和编排时的情况
copilotRuntimeNextJSAppRouterEndpoint:小助手函数,将 Copilot 运行时适配到 Next.js App Router API 路由,并返回 handleRequest 函数
在构建组件之前,我们先在 lib/types/research.ts 中定义待办事项、文件和信息源的共享状态。这些是 AI 智能体工具结果和本地 React 状态之间的契约。
// Uses local state + useDefaultTool instead of CoAgent (avoids type issues with Python FilesystemMiddleware)
export interface Todo {
id: string;
content: string;
status: "pending" | "in_progress" | "completed";
}
export interface ResearchFile {
path: string;
content: string;
createdAt: string;
}
// Sources found via internet_search (includes content)
export interface Source {
url: string;
title: string;
content?: string;
status: "found" | "scraped" | "failed";
}
export interface ResearchState {
todos: Todo[];
files: ResearchFile[];
sources: Source[];
}
export const INITIAL_STATE: ResearchState = {
todos: [],
files: [],
sources: [],
};
我们不直接把原始工具 JSON 转储到聊天中,而是让每个工具结果路由到专用状态槽位 —— write_todos 更新 todos,write_file 添加到 files,research 添加到 sources。这就是工作区面板的基础。
由于代码量很大,我这里只覆盖每个组件背后的核心逻辑。你可以在仓库的 src/components 目录中找到所有组件。
这是一个客户端组件,在聊天内联渲染每个工具调用。它有两种模式:
"use client";
import { useState } from "react";
import { Pencil, ClipboardList, Search, Save, BookOpen, Check, ChevronDown } from "lucide-react";
const TOOL_CONFIG = {
write_todos: {
icon: Pencil,
getDisplayText: () => "Updating research plan...",
getResultSummary: (result, args) => {
const todos = (args as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} updated`;
}
return null;
},
},
research: {
icon: Search,
getDisplayText: (args) =>
`Researching: ${((args.query as string) || "...").slice(0, 50)}${(args.query as string)?.length > 50 ? "..." : ""}`,
getResultSummary: (result) => {
if (result && typeof result === "object" && "sources" in result) {
const { sources } = result as { summary: string; sources: unknown[] };
return `Found ${sources.length} source${sources.length !== 1 ? "s" : ""}`;
}
return "Research complete";
},
},
write_file: {
icon: Save,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Writing: ${filename || "file"}`;
},
getResultSummary: (_result, args) => {
const content = args.content as string | undefined;
if (content) {
const firstLine = content.split("\n")[0].slice(0, 50);
return firstLine + (content.length > 50 ? "..." : "");
}
return "File written";
},
},
// read_todos, read_file follow the same pattern
};
export function ToolCard({ name, status, args, result }: ToolCardProps) {
const config = TOOL_CONFIG[name];
if (config) {
return (
<SpecializedToolCard
name={name}
status={status}
args={args}
result={result}
config={config}
/>
);
}
return (
<DefaultToolCard name={name} status={status} args={args} result={result} />
);
}
以下简要说明:
在这个组件中,ExpandedDetails 处理每种工具的展开视图。research 和 write_todos 会获得结构化布局;其他所有工具回退到 JSON 预格式块。
function ExpandedDetails({ name, result, args }) {
if (name === "research") {
const summary = typeof result === "object" && result && "summary" in result
? (result as any).summary
: "";
return (
<div>
<p>Query: {args.query as string}</p>
<p>{summary}</p>
</div>
);
}
if (name === "write_todos") { const todos = (args as any)?.todos; return ( <div> {todos?.map((todo: any, i: number) => ( <div key={todo.id || i}> <span>{todo.status === "completed" ? "✓" : todo.status === "inprogress" ? "●" : "○"}</span> <span>{todo.content}</span> </div> ))} </div> ); }
// Fallback return ( <pre> {typeof result === "string" ? result : JSON.stringify(result, null, 2)} </pre> ); }
完整代码请查看 src/components/ToolCard.tsx。
✅ Workspace 组件
这是右侧面板,用于实时显示研究进度。它包含三个可折叠部分:研究计划、文件和来源。每个部分都有实时数量徽章,并会在尚未收到任何内容时显示空状态。
export function Workspace({ state }: { state: ResearchState }) { const { todos, files, sources } = state; const fileCount = files.length; const todoCount = todos.length; const sourceCount = sources.length; // State for file viewer modal const [selectedFile, setSelectedFile] = useState<ResearchFile | null>(null);
return ( <div className="workspace-panel p-6"> <div className="mb-6"> <h2 className="text-xl font-bold">Workspace</h2> <p className="text-sm">Research progress and artifacts</p> </div> <Section title="Research Plan" icon={ListTodo} badge={todos.length}> <TodoList todos={todos} /> </Section> <Section title="Files" icon={FileText} badge={files.length}> <FileList files={files} onFileClick={setSelectedFile} /> </Section> <Section title="Sources" icon={Globe} badge={sources.length}> <SourceList sources={sources} /> </Section> <FileViewerModal file={selectedFile} onClose={() => setSelectedFile(null)} /> </div> ); }
function TodoList({ todos }: { todos: Todo[] }) { if (todos.length === 0) return <div>...</div>; // empty state
return (
<div className="space-y-1">
{todos.map((todo) => (
<div
key={todo.id}
className={todo-item ${ todo.status === "completed" ? "todo-item-completed" : todo.status === "in_progress" ? "todo-item-inprogress" : "todo-item-pending" }}
>
<span>{/* Check / CircleDot / Circle icon based on status */}</span>
<span className="text-sm">{todo.content}</span>
</div>
))}
</div>
);
}
// FileList — same pattern, items are clickable (onFileClick) // each row has a download button with e.stopPropagation() function FileList({ files, onFileClick, }: { files: ResearchFile[]; onFileClick: (file: ResearchFile) => void; }) { if (files.length === 0) return <div>...</div>; // empty state
return (
<div className="space-y-2">
{files.map((file, i) => (
<div
key={${file.path}-${i}}
className="file-item"
onClick={() => onFileClick(file)}
>
<div className="flex items-center gap-3">
{/* FileText icon /}
<div>
<p className="text-sm font-medium">
{file.path.split("/").pop()}
</p>
<p className="text-xs">{file.path}</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
downloadFile(file);
}}
>
{/ Download icon */}
</button>
</div>
))}
</div>
);
}
// SourceList — same pattern, colour-codes by source.status // (scraped → green check, failed → red X, found → grey circle) // each source links to source.url function SourceList({ sources }: { sources: Source[] }) { if (sources.length === 0) return <div>...</div>; // empty state
return (
<div className="space-y-2">
{sources.map((source, i) => (
<div
key={${source.url}-${i}}
className={file-item ${source.status === "failed" ? "source-failed" : ""}}
>
<span>{/* Check / X / Circle based on source.status */}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{source.title || new URL(source.url).hostname}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs truncate block"
>
{source.url}
</a>
</div>
</div>
))}
</div>
);
}
TodoList 会为每个待办事项渲染待处理 / 进行中 / 已完成图标。已完成的项目会添加删除线。
TodoList 会为每个待办事项渲染待处理 / 进行中 / 已完成图标。已完成的项目会添加删除线。
FileList 中的项目可以点击:点击后会打开 FileViewerModal,以完整的 Markdown 格式渲染内容,并提供下载按钮。
FileList 中的项目可以点击:点击后会打开 FileViewerModal,以完整的 Markdown 格式渲染内容,并提供下载按钮。
SourceList 会使用颜色区分来源状态:已抓取显示绿色勾号,失败显示红色 X,已发现但尚未抓取则显示灰色圆圈。
SourceList 会使用颜色区分来源状态:已抓取显示绿色勾号,失败显示红色 X,已发现但尚未抓取则显示灰色圆圈。
完整代码请查看 src/components/WorkSpace.tsx。
✅ FileViewerModal 组件
该组件会渲染一个模态框,用于显示 Deep Agent 写入的研究文件内容。它使用 react-markdown 包将文件内容渲染为格式化的 Markdown。
使用以下命令安装:
npm install react-markdown
以下是完整的核心实现:
export function FileViewerModal({ file, onClose }: FileViewerModalProps) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }, [onClose] );
useEffect(() => { if (file) { document.addEventListener("keydown", handleKeyDown); document.body.style.overflow = "hidden"; } return () => { document.removeEventListener("keydown", handleKeyDown); document.body.style.overflow = ""; }; }, [file, handleKeyDown]);
if (!file) return null;
const filename = file.path.split("/").pop() || file.path;
const handleDownload = () => { const blob = new Blob([file.content], {