编码代理需要的不仅仅是一个聊天窗口。它们需要文件浏览器、代码查看器和差异面板,即一种 IDE 体验。此模式将深度代理连接到沙箱 ,使其能够在隔离环境中读取、写入和执行代码,然后通过自定义 API 服务器暴露沙箱文件系统,以便前端可以在代理工作时实时显示文件。
沙箱模式有三个层次:
具有沙箱后端的深度代理: 代理从沙箱自动获取文件系统工具(read_file、write_file、edit_file、execute)
自定义 API 服务器: 一个通过 langgraph.json 的 http.app 字段暴露的 Hono 应用,提供前端可以调用的文件浏览端点
IDE 前端: 一个三面板布局(文件树、代码/差异查看器、聊天),在代理进行更改时实时同步文件
沙箱生命周期
在深入代码之前,了解沙箱的作用域很重要。作用域策略决定了谁共享一个沙箱、它存活多久以及如何在运行时解析它。
线程作用域沙箱(推荐)
每个 LangGraph 线程都有自己的沙箱。沙箱 ID 存储在线程的元数据中,并在运行时通过 getConfig() 解析。这是大多数应用程序的推荐方法:
对话是隔离的——一个线程中的文件更改不会影响另一个线程
沙箱状态在页面重新加载后持续存在(相同线程 = 相同沙箱)
清理很简单:当线程被删除时,其沙箱也可以被删除
代理作用域沙箱
同一助手下的所有线程共享一个沙箱。适用于需要跨对话保留更改的持久项目环境:
import { getConfig } from "@langchain/langgraph" ;
function getSandboxBackendForAssistant () {
const config = getConfig () ;
const assistantId = config . metadata ?. assistant_id ;
return getOrCreateSandboxForAssistant (assistantId) ;
}
用户作用域沙箱
每个用户在所有线程中都有自己的沙箱。需要自定义身份验证和用户识别:
import { getConfig } from "@langchain/langgraph" ;
function getSandboxBackendForUser () {
const config = getConfig () ;
const userId = config . configurable ?. user_id ;
return getOrCreateSandboxForUser (userId) ;
}
会话作用域沙箱(客户端)
对于没有 LangGraph 线程的简单应用程序,前端可以生成会话 ID 并直接传递。这种方法不会跨浏览器会话持久化,最适合演示或原型设计:
const sessionId = crypto . randomUUID () ;
fetch ( `/api/sandbox/tree?sessionId= ${ sessionId } ` ) ;
本指南的其余部分使用线程作用域沙箱 作为主要示例。
设置代理
选择沙箱提供商
深度代理支持多种沙箱提供商。任何实现 SandboxBackendProtocol 的提供商都可以:
import { createDeepAgent , LangSmithSandbox } from "deepagents" ;
const sandbox = await LangSmithSandbox . create () ;
export const agent = createDeepAgent ( {
model : "google_genai:gemini-3.1-pro-preview" ,
backend : sandbox ,
systemPrompt : "You are an expert developer working on a project in /app." ,
} ) ;
代理自动获取文件系统工具(read_file、write_file、edit_file、ls、glob、grep)和一个用于运行 shell 命令的 execute 工具。无需工具配置。
为每个线程解析沙箱
不要在模块级别创建沙箱(这会在所有线程之间共享并可能过期),而是在运行时按线程解析沙箱。沙箱通过 getConfig() 从 LangGraph 配置中读取 thread_id:
import { createDeepAgent , LangSmithSandbox } from "deepagents" ;
import { getConfig } from "@langchain/langgraph" ;
async function getOrCreateSandboxForThread ( threadId : string ) : Promise < LangSmithSandbox > {
// 检查线程元数据中是否存在现有的 sandbox_id
const client = new Client ( { apiUrl : "http://localhost:2024" } ) ;
const thread = await client . threads . get (threadId) ;
const sandboxId = thread . metadata ?. sandbox_id ;
if (sandboxId) {
// 重新连接到现有沙箱
return new LangSmithSandbox ( {
sandbox : await new SandboxClient () . getSandbox (sandboxId) ,
} ) ;
}
// 创建新沙箱并将 ID 存储在线程元数据中
const sandbox = await LangSmithSandbox . create ( { templateName : "my-template" } ) ;
await seedSandbox (sandbox) ;
await client . threads . update (threadId , { metadata : { sandbox_id : sandbox . id } } ) ;
return sandbox ;
}
// 创建一个在运行时按线程解析的沙箱
const sandbox = new LangSmithSandbox ( {
resolve : async () => {
const config = getConfig () ;
const threadId = config . configurable ?. thread_id ;
if ( ! threadId) throw new Error ( "No thread_id — agent must run on a thread" ) ;
return getOrCreateSandboxForThread (threadId) ;
},
} ) ;
export const agent = createDeepAgent ( {
model : "google_genai:gemini-3.1-pro-preview" ,
backend : sandbox ,
systemPrompt : "You are an expert developer working on a project in /app." ,
} ) ;
填充沙箱
在代理运行之前,使用 uploadFiles 将项目文件填充到沙箱中:
对于 LangSmith 沙箱,容器镜像和资源限制来自沙箱快照 。创建沙箱时传递 templateName(参见上面的 getOrCreateSandboxForThread)。uploadFiles 在运行时在该镜像基础上填充或更新项目文件。
const SEED_FILES : Record < string , string > = {
"package.json" : JSON . stringify ( { name : "my-app" , version : "1.0.0" }, null , 2 ) ,
"src/index.js" : 'console.log("Hello");' ,
};
const encoder = new TextEncoder () ;
await sandbox . uploadFiles (
Object . entries (SEED_FILES) . map ( ([ path , content ]) => [ `/app/ ${ path } ` , encoder . encode (content)]) ,
) ;
在上传 package.json 后运行 sandbox.execute("cd /app && npm install") 以在代理开始之前安装依赖项。
添加文件浏览 API
代理可以读写文件,但前端也需要直接访问以浏览沙箱文件系统。添加一个自定义 Hono API 服务器,并通过 langgraph.json 中的 http.app 字段暴露它。
创建 API 服务器
沙箱 API 端点使用线程 ID 作为 URL 路径参数。这确保前端始终访问当前对话的正确沙箱,使用与代理后端相同的 getOrCreateSandboxForThread 函数:
// src/api/app.ts
import { Hono } from "hono" ;
import { getOrCreateSandboxForThread } from "./utils.js" ;
export const app = new Hono () ;
app . get ( "/api/sandbox/:threadId/tree" , async ( c ) => {
const threadId = c . req . param ( "threadId" ) ;
const rootPath = c . req . query ( "filePath" ) || "/app" ;
const sandbox = await getOrCreateSandboxForThread (threadId) ;
const result = await sandbox . execute (
`find ' ${ rootPath } ' -printf '%y \\ t%s \\ t%p \\ n' 2>/dev/null | sort -t$' \\ t' -k3` ,
) ;
const entries = result . output
. trim ()
. split ( " \n " )
. filter (Boolean)
. map ( ( line ) => {
const [ typeChar , sizeStr , fullPath ] = line . split ( " \t " ) ;
return {
name : fullPath . split ( "/" ) . pop () ,
type : typeChar === "d" ? "directory" : "file" ,
path : fullPath ,
size : parseInt (sizeStr , 10 ) || 0 ,
};
} ) ;
return c . json ( { path : rootPath , entries , sandboxId : sandbox . id } ) ;
} ) ;
app . get ( "/api/sandbox/:threadId/file" , async ( c ) => {
const threadId = c . req . param ( "threadId" ) ;
const filePath = c . req . query ( "filePath" ) ;
if ( ! filePath) return c . json ( { error : "filePath is required" }, 400 ) ;
const sandbox = await getOrCreateSandboxForThread (threadId) ;
const results = await sandbox . downloadFiles ([filePath]) ;
const file = results[ 0 ] ;
if (file . error) return c . json ( { error : file . error }, 404 ) ;
const content = new TextDecoder () . decode (file . content ! ) ;
return c . json ( { path : filePath , content } ) ;
} ) ;
代理的后端和 API 服务器都调用相同的 getOrCreateSandboxForThread 函数。这确保它们始终为给定线程解析到相同的沙箱。线程元数据中的沙箱 ID 是唯一的事实来源——不需要内存缓存。
配置 langgraph.json
注册代理图和 API 服务器。http.app 字段告诉 LangGraph 平台在默认路由旁边提供您的自定义路由:
{
" node_version " : "22" ,
" graphs " : {
" coding_agent " : "./src/agents/my-agent.ts:agent"
},
" env " : ".env" ,
" http " : {
" app " : "./src/api/app.ts:app"
}
}
您的自定义路由与 LangGraph API 位于同一主机。对于使用 langgraph dev 的本地开发,该主机是 http://localhost:2024。
在 http.app 中定义的自定义路由优先于默认的 LangGraph 路由。这意味着您可以根据需要覆盖内置端点,但要小心不要意外覆盖像 /threads 或 /runs 这样的路由。
构建前端
前端有三个面板:文件树侧边栏、代码/差异查看器和聊天面板。它使用 useStream 进行代理对话,使用自定义 API 端点进行文件浏览。
线程创建
在页面加载时创建一个 LangGraph 线程,并将其 ID 持久化到 sessionStorage 中,以便页面重新加载时重新连接到相同的沙箱:
const THREAD_KEY = "sandbox-thread-id" ;
function IDEPreview () {
const [ threadId , setThreadId ] = useState < string | null > (
() => sessionStorage . getItem ( THREAD_KEY ) ,
) ;
const updateThreadId = useCallback ( ( id : string | null ) => {
setThreadId (id) ;
if (id) sessionStorage . setItem ( THREAD_KEY , id) ;
else sessionStorage . removeItem ( THREAD_KEY ) ;
}, []) ;
const stream = useStream < typeof myAgent > ( {
apiUrl : AGENT_URL ,
assistantId : "coding_agent" ,
threadId ,
onThreadId : updateThreadId ,
} ) ;
// 首次挂载时创建线程
useEffect ( () => {
if (threadId) return ;
stream . client . threads . create () . then ( ( t ) => updateThreadId (t . thread_id)) ;
}, [stream . client , threadId , updateThreadId]) ;
// 将 threadId 传递给沙箱文件钩子
const { tree , files } = useSandboxFiles (threadId) ;
// ...
}
“新线程”按钮清除存储的 ID,以便下次挂载时创建一个新线程(和沙箱):
function handleNewThread () {
stream . switchThread ( null ) ;
updateThreadId ( null ) ;
}
文件状态管理
跟踪沙箱文件系统的两个快照:原始状态(代理运行前)和当前状态(实时更新)。线程 ID 包含在 API URL 中,因此请求始终命中正确的沙箱:
const AGENT_URL = "http://localhost:2024" ;
async function fetchTree ( threadId : string ) : Promise < FileEntry[] > {
const res = await fetch (
` ${ AGENT_URL } /api/sandbox/ ${ encodeURIComponent ( threadId ) } /tree?filePath=/app` ,
) ;
const data = await res . json () ;
return data . entries . filter ( ( e : FileEntry ) => ! e . path . includes ( "node_modules" )) ;
}
async function fetchFile ( threadId : string , path : string ) : Promise < string | null > {
const res = await fetch (
` ${ AGENT_URL } /api/sandbox/ ${ encodeURIComponent ( threadId ) } /file?filePath= ${ encodeURIComponent ( path ) } ` ,
) ;
const data = await res . json () ;
return data . content ?? null ;
}
实时文件同步
IDE 体验的关键是在代理工作时 更新文件,而不是在它完成后。监视流中的消息以查找来自文件修改工具的 ToolMessage 实例。当 write_file 或 edit_file 工具调用完成时,刷新该特定文件。当 execute 完成时,刷新所有内容(因为 shell 命令可能修改任何文件):
import { useStream } from "@langchain/react" ;
import { ToolMessage , AIMessage } from "langchain" ;
const FILE_MUTATING_TOOLS = new Set ([ "write_file" , "edit_file" , "execute" ]) ;
export function IDEPreview () {
const stream = useStream < typeof myAgent > ( {
apiUrl : AGENT_URL ,
assistantId : "coding_agent" ,
} ) ;
const processedIds = useRef ( new Set < string > ()) ;
useEffect ( () => {
// 从 AI 消息构建文件修改工具调用的映射
const toolCallMap = new Map () ;
for ( const msg of stream . messages) {
if ( ! AIMessage . isInstance (msg)) continue ;
for ( const tc of msg . tool_calls ?? []) {
if (tc . id && FILE_MUTATING_TOOLS . has (tc . name)) {
toolCallMap . set (tc . id , { name : tc . name , args : tc . args } ) ;
}
}
}
// 当文件修改工具的 ToolMessage 出现时,刷新
for ( const msg of stream . messages) {
if ( ! ToolMessage . isInstance (msg)) continue ;
const id = msg . id ?? msg . tool_call_id ;
if ( ! id || processedIds . current . has (id)) continue ;
const call = toolCallMap . get (msg . tool_call_id) ;
if ( ! call) continue ;
processedIds . current . add (id) ;
if (call . name === "write_file" || call . name === "edit_file" ) {
refreshSingleFile (call . args . path) ;
} else if (call . name === "execute" ) {
refreshAllFiles () ;
}
}
}, [stream . messages]) ;
}
检测更改的文件
在每次代理运行之前,对当前文件内容进行快照。文件刷新后,与快照进行比较以识别哪些文件发生了更改:
function detectChanges ( current : FileSnapshot , original : FileSnapshot ) : Set < string > {
const changed = new Set < string > () ;
for ( const [ path , content ] of Object . entries (current)) {
if (original[path] !== content) changed . add (path) ;
}
for ( const path of Object . keys (original)) {
if ( ! (path in current)) changed . add (path) ;
}
return changed ;
}
当用户选择一个已更改的文件时,默认显示差异视图,以便他们立即看到代理修改了什么。
显示差异
使用适合框架的差异库来渲染统一差异:
框架 库 组件 React @pierre/diffs<FileDiff> 配合 parseDiffFromFileVue @git-diff-view/vue<DiffView> 配合来自 @git-diff-view/file 的 generateDiffFileSvelte @git-diff-view/svelte<DiffView> 配合来自 @git-diff-view/file 的 generateDiffFileAngular ngx-diff<ngx-unified-diff> 配合 [before] 和 [after]
使用 @pierre/diffs (React) 的示例:
import { FileDiff } from "@pierre/diffs/react" ;
import { parseDiffFromFile } from "@pierre/diffs" ;
function DiffPanel ({ original , current , fileName }) {
const diff = parseDiffFromFile (
{ name : fileName , contents : original },
{ name : fileName , contents : current },
) ;
return (
< FileDiff
fileDiff = { diff }
options = {{ theme : "github-dark" , diffStyle : "unified" , diffIndicators : "bars" }}
/>
) ;
}
已更改文件摘要
显示所有已修改文件的摘要,包括行级添加/删除计数。这为用户提供了代理影响的快速概览——类似于 git status:
function ChangedFilesSummary ({ changedFiles , files , originalFiles , onSelect }) {
const stats = [ ... changedFiles] . map ( ( path ) => {
const oldLines = (originalFiles[path] ?? "" ) . split ( " \n " ) ;
const newLines = (files[path] ?? "" ) . split ( " \n " ) ;
// 通过比较行来计算添加/删除
return { path , additions , deletions };
} ) ;
return (
< div >
< h3 > { stats . length } 个文件已更改 </ h3 >
{ stats . map ( ( file ) => (
< button key = { file . path } onClick = {() => onSelect (file . path) } >
{ file . path }
< span className = "text-green-400" > + { file . additions } </ span >
< span className = "text-red-400" > - { file . deletions } </ span >
</ button >
)) }
</ div >
) ;
}
三面板布局
IDE 布局将三个面板并排排列:
面板 宽度 用途 文件树 固定 (208px) 浏览沙箱文件,查看更改指示器 代码/差异 灵活 查看文件内容或统一差异 聊天 固定 (320px) 与代理交互
< div className = "flex h-screen" >
< div className = "w-52 shrink-0" >
< FileTree />
< ChangedFilesSummary />
</ div >
< CodePanel /* flex-1 */ />
< div className = "w-80 shrink-0" >
< ChatPanel />
</ div >
</ div >
文件树显示 VS Code 风格的图标(使用 @iconify-json/vscode-icons )和修改文件上的琥珀色点。选择修改的文件会自动切换到差异选项卡。
当以下情况时,沙箱是正确的选择:
编码代理 创建、修改和运行代码需要聊天之外的视觉界面
代码审查工作流 ,代理建议更改,用户在接受之前审查差异
教程或学习应用 ,AI 助手帮助用户逐步构建项目,在上下文中显示更改
原型设计工具 ,用户用自然语言描述功能,并实时观看代理实现它们
最佳实践
在生产应用中使用线程作用域沙箱 。将沙箱 ID 存储在线程元数据中,并在运行时通过 getConfig() 解析。这避免了模块级状态,并保持沙箱按对话隔离。
在代理后端和 API 服务器之间共享 getOrCreateSandboxForThread 。两者都应以相同的方式解析沙箱——通过线程元数据——这样就有唯一的事实来源,无需内存缓存。
在 sessionStorage 中持久化 threadId ,以便页面重新加载时重新连接到相同的线程和沙箱,而不是创建新的。
在每个相关工具调用时同步文件 ,而不仅仅是在运行完成后。这使 IDE 感觉是实时的。监视 write_file、edit_file 和 execute 工具消息并立即刷新。
默认为已更改文件显示差异视图 。当用户单击被代理修改的文件时,首先显示差异——这是他们关心的。
为只读操作显示紧凑的工具结果 。不要在聊天中转储 read_file 的完整输出,而是显示一行内容,如 Read router.js L1-42。将完整输出显示保留给修改工具。
用真实项目填充沙箱 。从空沙箱开始会让人迷失方向。上传一个可运行的入门项目,以便用户(和代理)立即拥有上下文。
从文件树中过滤 node_modules 。没有人想浏览数千个依赖文件。在获取树时将它们过滤掉。
将这些文档连接 到 Claude、VSCode 等,通过 MCP 获取实时答案。