与 AI 代理的对话很少是线性的。你可能想要重新表述一个问题、重新生成一个你不满意的响应,或者探索一条完全不同的对话路径而不丢失之前的工作。分支聊天将版本控制语义引入你的聊天界面。每次编辑都会创建一个新分支,你可以自由地在它们之间导航。
什么是分支聊天?
分支聊天将对话视为一棵树,而不是一个列表。每条消息都是一个节点,编辑消息或重新生成响应会从该点创建一个分支 。原始路径作为同级分支保留,因此用户可以在不同的对话轨迹之间来回切换。
关键功能:
编辑任何用户消息: 重写之前的提示并从该点重新运行代理
重新生成任何 AI 响应: 要求代理为相同的输入产生不同的答案
导航分支: 使用每条消息的分支控件在对话的不同版本之间切换
设置带历史记录的 useStream
要启用分支功能,请传递 fetchStateHistory: true,以便 useStream 检索分支操作所需的检查点元数据。
导入你的代理并将 typeof myAgent 作为类型参数传递给 useStream,以实现对状态值的类型安全访问:
import type { myAgent } from "./agent" ;
import { useStream } from "@langchain/react" ;
const AGENT_URL = "http://localhost:2024" ;
export function Chat () {
const stream = useStream < typeof myAgent > ( {
apiUrl : AGENT_URL ,
assistantId : "branching_chat" ,
fetchStateHistory : true ,
} ) ;
return (
< div >
{ stream . messages . map ( ( msg ) => {
const metadata = stream . getMessagesMetadata (msg) ;
return (
< Message
key = { msg . id }
message = { msg }
metadata = { metadata }
onEdit = {( text ) => handleEdit (stream , msg , metadata , text) }
onRegenerate = {() => handleRegenerate (stream , metadata) }
onBranchSwitch = {( id ) => stream . setBranch (id) }
/>
) ;
} ) }
</ div >
) ;
}
理解消息元数据
getMessagesMetadata(msg) 函数返回每条消息的分支信息:
interface MessageMetadata {
branch : string ;
branchOptions : string [] ;
firstSeenState : {
parent_checkpoint : Checkpoint | null ;
};
}
属性 描述 branch此特定消息版本的分支 ID branchOptions此消息位置所有可用分支 ID 的数组 firstSeenState.parent_checkpoint此消息之前的检查点。将其用作编辑和重新生成的分支点
当一条消息只有一个版本时,branchOptions 包含一个条目。在编辑或重新生成后,新的分支 ID 会被添加到 branchOptions,你可以在它们之间导航。
编辑消息
要编辑用户消息并创建新分支:
从消息的元数据中获取 parent_checkpoint
使用该检查点提交编辑后的消息
代理从该点重新运行,创建一个新分支
function handleEdit (
stream : ReturnType < typeof useStream > ,
originalMsg : HumanMessage ,
metadata : MessageMetadata ,
newText : string
) {
const checkpoint = metadata . firstSeenState ?. parent_checkpoint ;
if ( ! checkpoint) return ;
stream . submit (
{
messages : [ { ... originalMsg , content : newText } ] ,
},
{ checkpoint }
) ;
}
编辑后:
消息的 branchOptions 会获得一个新条目
视图会自动切换到新分支
代理从分支点使用更新后的消息重新运行
原始版本被保留,并可通过分支切换器访问
重新生成响应
要在不更改输入的情况下重新生成 AI 响应:
从 AI 消息的元数据中获取 parent_checkpoint
使用 undefined 输入和父检查点提交
代理生成一个新的响应,创建一个新分支
function handleRegenerate (
stream : ReturnType < typeof useStream > ,
metadata : MessageMetadata
) {
const checkpoint = metadata . firstSeenState ?. parent_checkpoint ;
if ( ! checkpoint) return ;
stream . submit ( undefined , { checkpoint } ) ;
}
每次重新生成都会为该位置的 AI 消息创建一个新分支。然后用户可以使用分支切换器来比较不同的响应。
重新生成对于非确定性代理很有用。由于 LLM 的输出随温度变化,重新生成相同的提示通常会产生有意义的不同响应。
构建分支切换器
当一条消息有多个分支时,显示一个紧凑的内联控件,包含当前版本索引和导航箭头:
function BranchSwitcher ({
metadata ,
onSwitch ,
} : {
metadata : MessageMetadata ;
onSwitch : ( branchId : string ) => void ;
}) {
const { branch , branchOptions } = metadata ;
if (branchOptions . length <= 1 ) return null ;
const currentIndex = branchOptions . indexOf (branch) ;
const hasPrev = currentIndex > 0 ;
const hasNext = currentIndex < branchOptions . length - 1 ;
return (
< div className = "inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600" >
< button
disabled = { ! hasPrev }
onClick = {() => onSwitch (branchOptions[currentIndex - 1 ]) }
className = "hover:text-gray-900 disabled:opacity-30"
aria-label = "Previous version"
>
◀
</ button >
< span className = "min-w-[3ch] text-center" >
{ currentIndex + 1 } / { branchOptions . length }
</ span >
< button
disabled = { ! hasNext }
onClick = {() => onSwitch (branchOptions[currentIndex + 1 ]) }
className = "hover:text-gray-900 disabled:opacity-30"
aria-label = "Next version"
>
▶
</ button >
</ div >
) ;
}
当用户点击分支箭头时,调用 stream.setBranch(branchId) 将对话视图切换到该分支。这是即时的,因为所有分支数据已通过 fetchStateHistory: true 加载。
切换分支不仅影响目标消息,还影响所有后续消息。如果你切换到消息 3 的不同版本,消息 4、5、6 等也将更新以反映跟随该版本的对话。
分支在底层如何工作
LangGraph 将每个状态转换持久化为一个检查点 。当你使用 checkpoint 参数提交时,后端会从该点分叉,而不是追加到当前对话。结果是一个树状结构:
User: "What is React?"
└─ AI: "React is a JavaScript library..." (branch A)
└─ AI: "React is a UI framework..." (branch B, regenerated)
User: "Tell me about hooks" (branch A)
└─ AI: "Hooks are functions..."
User: "Tell me about JSX" (edited from branch A)
└─ AI: "JSX is a syntax extension..."
每个分支都是对话树中的独立路径。切换分支会更新显示的消息,但不会删除任何数据。所有分支都持久化在检查点存储中。
完整的消息组件
这是一个结合了消息显示、编辑、重新生成和分支切换的完整组件:
function MessageWithBranching ({
message ,
metadata ,
stream ,
} : {
message : BaseMessage ;
metadata : MessageMetadata ;
stream : ReturnType < typeof useStream > ;
}) {
const [ isEditing , setIsEditing ] = useState ( false ) ;
const [ editText , setEditText ] = useState (message . content as string ) ;
const isHuman = message . _getType () === "human" ;
const isAI = message . _getType () === "ai" ;
const hasBranches = metadata . branchOptions . length > 1 ;
return (
< div className = "group relative py-2" >
{ isEditing ? (
< EditForm
text = { editText }
onChange = { setEditText }
onSave = {() => {
handleEdit (stream , message as HumanMessage , metadata , editText) ;
setIsEditing ( false ) ;
}}
onCancel = {() => {
setEditText (message . content as string ) ;
setIsEditing ( false ) ;
}}
/>
) : (
<>
< div className = { isHuman ? "text-right" : "text-left" } >
< div
className = {
isHuman
? "inline-block rounded-lg bg-blue-600 px-4 py-2 text-white"
: "inline-block rounded-lg bg-gray-100 px-4 py-2"
}
>
{ message . content as string }
</ div >
</ div >
< div className = "mt-1 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100" >
{ isHuman && (
< button
className = "text-xs text-gray-400 hover:text-gray-700"
onClick = {() => setIsEditing ( true ) }
>
Edit
</ button >
) }
{ isAI && (
< button
className = "text-xs text-gray-400 hover:text-gray-700"
onClick = {() =>
handleRegenerate (stream , metadata)
}
>
Regenerate
</ button >
) }
{ hasBranches && (
< BranchSwitcher
metadata = { metadata }
onSwitch = {( id ) => stream . setBranch (id) }
/>
) }
</ div >
</>
) }
</ div >
) ;
}
function EditForm ({
text ,
onChange ,
onSave ,
onCancel ,
} : {
text : string ;
onChange : ( text : string ) => void ;
onSave : () => void ;
onCancel : () => void ;
}) {
return (
< div className = "space-y-2" >
< textarea
className = "w-full rounded-lg border p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
value = { text }
onChange = {( e ) => onChange (e . target . value) }
rows = { 3 }
/>
< div className = "flex gap-2" >
< button
className = "rounded bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
onClick = { onSave }
>
Save & Rerun
</ button >
< button
className = "rounded border px-4 py-1.5 text-sm hover:bg-gray-50"
onClick = { onCancel }
>
Cancel
</ button >
</ div >
</ div >
) ;
}
与乐观更新结合
将分支与乐观更新结合,以实现无缝的编辑体验。当用户保存编辑时,在服务器响应之前乐观地显示更新后的消息:
function handleEditOptimistic (
stream : ReturnType < typeof useStream > ,
originalMsg : HumanMessage ,
metadata : MessageMetadata ,
newText : string
) {
const checkpoint = metadata . firstSeenState ?. parent_checkpoint ;
if ( ! checkpoint) return ;
const updatedMsg = { ... originalMsg , content : newText };
stream . submit (
{ messages : [updatedMsg] },
{
checkpoint ,
optimisticValues : ( prev ) => {
if ( ! prev ?. messages) return { messages : [updatedMsg] };
const idx = prev . messages . findIndex ( ( m ) => m . id === originalMsg . id) ;
if (idx === - 1 ) return prev ;
return {
... prev ,
messages : [ ... prev . messages . slice ( 0 , idx) , updatedMsg] ,
};
},
}
) ;
}
添加键盘导航
为高级用户添加键盘快捷键来导航分支:
useEffect ( () => {
function handleKeyDown ( e : KeyboardEvent ) {
if ( ! focusedMessageMetadata) return ;
const { branch , branchOptions } = focusedMessageMetadata ;
const idx = branchOptions . indexOf (branch) ;
if (e . altKey && e . key === "ArrowLeft" && idx > 0 ) {
stream . setBranch (branchOptions[idx - 1 ]) ;
}
if (e . altKey && e . key === "ArrowRight" && idx < branchOptions . length - 1 ) {
stream . setBranch (branchOptions[idx + 1 ]) ;
}
}
window . addEventListener ( "keydown" , handleKeyDown) ;
return () => window . removeEventListener ( "keydown" , handleKeyDown) ;
}, [focusedMessageMetadata , stream]) ;
Alt + ← / Alt + → 是分支导航的自然映射,因为它反映了浏览器的后退/前进导航。
最佳实践
始终启用 fetchStateHistory :没有它,getMessagesMetadata 无法返回分支信息。
仅在存在多个分支时显示分支切换器 :1/1 指示器会增加杂乱而没有价值。
在悬停时显示分支控件 :分支导航箭头和编辑按钮应在悬停时出现,以保持界面整洁。
保持分支切换器紧凑 :它内联于消息控件中,不应主导界面。
保留滚动位置 :切换分支时,尝试将视口锚定到已更改的消息。
指示活动分支 :使用微妙的视觉提示(例如,彩色点或分支标签),以便用户知道他们正在查看哪个分支。
在流式传输时禁用控件 :在代理主动流式传输响应时,不允许编辑或重新生成。在启用这些操作之前检查 stream.isLoading。
取消时保留编辑文本 :如果用户开始编辑然后取消,将文本区域重置为原始消息内容。
使用深层分支树进行测试 :频繁编辑和重新生成的用户可能会创建许多分支。确保分支切换器和数据处理保持高性能。
将这些文档连接 到 Claude、VSCode 等,通过 MCP 获取实时答案。