从SGLang RadixAttention到KV Cache复用革命:LLM推理服务的前缀感知调度与缓存池化架构
引言:LLM推理的"重复计算税"
在生产级LLM推理服务中,存在一个令人震惊的效率黑洞:大量GPU算力被浪费在处理重复内容上。当一个企业应用为每次请求注入2000 token的系统提示词(System Prompt),或者一个对话Agent在多轮交互中反复携带历史上下文时,这些相同的前缀token会被反复计算、反复生成KV Cache。
更极端的情况是Few-shot Learning场景——用户在prompt中附带5个示例对话,而基础系统提示词长达4000 token。这意味着每次推理请求中,可能有60-70%的计算量消耗在完全相同的内容上。在没有前缀复用机制的vLLM原始部署中,这些KV Cache虽然已经存在于GPU显存中,却因为虚拟机隔离(每个请求独占一个KV Cache空间)而无法被后续请求利用。
2024年,UC Berkeley的Lianmin Zheng(也是vLLM的作者)在SGLang项目中提出了RadixAttention这一创新方案,通过Radix Tree数据结构实现了跨请求的KV Cache前缀复用。这一机制让LLM推理服务的吞吐量提升了3-5倍,首Token延迟降低了60%以上。本文将从底层数据结构到分布式调度策略,全面拆解这一正在重塑LLM Serving生态的核心技术。
一、从PagedAttention到RadixAttention:KV Cache管理范式的跃迁
1.1 vLLM PagedAttention的设计哲学
vLLM借鉴操作系统虚拟内存管理思想,提出了PagedAttention机制:将KV Cache切分为固定大小的block(通常16个token/块),通过block table实现逻辑连续性与物理地址的解耦。这一设计解决了显存碎片问题,使显存利用率从不到40%提升到95%以上。
# PagedAttention的Block Table机制简化示意
class BlockTable:
"""每个请求维护一个block table,逻辑块号→物理块号映射"""
def __init__(self, num_blocks: int):
self.logical_to_physical: Dict[int, int] = {} # 逻辑块 → 物理块
self.ref_count: Dict[int, int] = {} # 物理块引用计数(用于Copy-on-Write)
def allocate_block(self, logical_idx: int) -> int:
physical_idx = self.broker.acquire_free_block()
self.logical_to_physical[logical_idx] = physical_idx
self.ref_count[physical_idx] = 1
return physical_idx
def cow_if_shared(self, logical_idx: int) -> int:
"""写时复制:当需要修改被共享的块时触发复制"""
physical_idx = self.logical_to_physical[logical_idx]
if self.ref_count[physical_idx] > 1:
new_physical = self.broker.acquire_free_block()
copy_block(physical_idx, new_physical)
self.ref_count[physical_idx] -= 1
self.ref_count[new_physical] = 1
self.logical_to_physical[logical_idx] = new_physical
return new_physical
return physical_idx
PagedAttention解决了单个请求内部的内存碎片问题,但各请求之间的KV Cache是完全隔离的。两个相同System Prompt的请求会各自独立计算前缀的KV Cache,产生双倍的冗余计算。
1.2 RadixAttention的核心洞见
RadixAttention的洞察在于:基于LLM推理请求的token序列共享前缀的普遍性,构建一个全局前缀树(Radix Tree),将各请求的KV Cache物理存储从中分离,改为引用公共树节点。
import weakref
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import time
@dataclass
class RadixNode:
"""
Radix Tree节点:存储一段连续token序列共享的KV Cache
与压缩Trie不同,Radix Tree的边携带的是token子序列,
这使得前缀匹配可以O(k)完成,k为匹配前缀长度(非树高)。
"""
token_ids: Tuple[int, ...] # 该节点对应的token序列片段
block_indices: List[int] # 该片段在GPU显存中对应的KV Cache块索引表
children: Dict[int, 'RadixNode'] = field(default_factory=dict) # 子节点:首token → 子节点
ref_count: int = 0 # 活跃引用计数(用于GC决策)
last_access_time: float = 0.0 # LRU时间戳
priority: int = 0 # 优先级(保护高价值前缀不被驱逐)
class RadixCache:
"""
SGLang RadixAttention的核心数据结构
设计约束:
1. 树中的每个节点对应一段共享的KV Cache物理存储
2. 叶子节点可能被多个"正在Run的Sequence"引用
3. 非活跃节点在显存压力下按LRU-K策略逐出
"""
def __init__(self, block_size: int = 16, gpu_kv_capacity: int = 100000):
self.root = RadixNode(token_ids=(), block_indices=[])
self.block_size = block_size
self.total_gpu_blocks = gpu_kv_capacity
self.free_blocks = list(range(gpu_kv_capacity))
self.node_to_gpu_ptr: Dict[int, List[int]] = {} # node id → 物理块列表
self._clock = 0
def match_prefix(self, token_ids: Tuple[int, ...]) -> Tuple[RadixNode, int, Tuple[int, ...]]:
"""
在Radix Tree中查找与给定token序列的最长公共前缀
返回值: (匹配到的最后一个节点, 已匹配的token数量, 未匹配的剩余token)
这是RadixAttention最关键的操作,调度器在每次Insert请求时调用它
时间复杂度: O(min(k, tree_depth * avg_edge_length))
"""
node = self.root
matched = 0
remaining = token_ids
while remaining:
first_token = remaining[0]
if first_token not in node.children:
break
child = node.children[first_token]
edge_tokens = child.token_ids
edge_len = len(edge_tokens)
# 比较edge上的token子序列
if remaining[:edge_len] == edge_tokens:
# 完全匹配当前边
node = child
matched += edge_len
remaining = remaining[edge_len:]
else:
# 部分匹配:需要分裂edge
common_len = 0
for i in range(min(edge_len, len(remaining))):
if remaining[i] == edge_tokens[i]:
common_len += 1
else:
break
node = self._split_edge(node, child, first_token, common_len)
matched += common_len
remaining = remaining[common_len:]
break
return node, matched, remaining
def _split_edge(self, parent: RadixNode, child: RadixNode,
first_token: int, split_pos: int) -> RadixNode:
"""
Edge分裂:当新请求与已有edge部分匹配时,将edge拆分为两段
这是RadixTree区别于普通Trie的关键——边可能承载多个token,
部分匹配时需要精确分裂以最大化后缀复用潜力。
分裂时必须处理KV Cache Phys Block的重新分配:
- 公共前缀完全共享原child的blocks[0:split_blocks]
- 剩余部分物理复制(因为原child中的数据对后来请求仍然有效)
"""
edge_tokens = child.token_ids
common_tokens = edge_tokens[:split_pos]
diverge_tokens = edge_tokens[split_pos:]
# 计算需要多少物理块存储公共前缀
split_blocks = (split_pos + self.block_size - 1) // self.block_size
# 创建新的中间节点作为分裂点
mid_node = RadixNode(
token_ids=common_tokens,
block_indices=child.block_indices[:split_blocks],
last_access_time=time.time(),
ref_count=child.ref_count # 初始继承引用计数
)
self.node_to_gpu_ptr[id(mid_node)] = mid_node.block_indices
# 将child调整为mid_node的子节点,携带diverge部分
child.token_ids = diverge_tokens
child.block_indices = child.block_indices[split_blocks:]
diverge_first = diverge_tokens[0]
mid_node.children[diverge_first] = child
# 替换parent中原child的引用
parent.children[first_token] = mid_node
return mid_node
def insert(self, token_ids: Tuple[int, ...], priority: int = 0) -> Tuple[int, List[int]]:
"""
插入新请求到Radix Tree
流程:
1. match_prefix找到最长匹配
2. 将未匹配的后缀作为新节点插入
3. 分配新的KV Cache blocks
返回: (已匹配的token数量, 新分配的block索引列表)
"""
node, matched, remaining = self.match_prefix(token_ids)
new_blocks = []
if remaining:
# 先尝试驱逐以回收足够的blocks
needed_blocks = (len(remaining) + self.block_size - 1) // self.block_size
while len(self.free_blocks) < needed_blocks:
self._evict_one()
# 为新后缀分配blocks
for i in range(0, len(remaining), self.block_size):
chunk = remaining[i:i + self.block_size]
block = self.free_blocks.pop(0)
new_blocks.append(block)
# 创建新叶子节点
first_token = remaining[0]
leaf = RadixNode(
token_ids=remaining,
block_indices=new_blocks,
priority=priority,
last_access_time=time.time()
)
node.children[first_token] = leaf
self.node_to_gpu_ptr[id(leaf)] = new_blocks
# 更新所有被引用节点的access时间
self._touch_path(node)
return matched, new_blocks
def _evict_one(self) -> int:
"""LRU-K逐出策略:优先逐出最久未访问的低优先级叶子节点"""
victim = self._find_lru_leaf()
if victim is None:
raise MemoryError("No evictable nodes in Radix Cache!")
freed = victim.block_indices
self.free_blocks.extend(freed)
# 从父节点删除引用
parent = self._find_parent(victim)
if parent:
first_tok = victim.token_ids[0]
del parent.children[first_tok]
self.node_to_gpu_ptr.pop(id(victim), None)
return len(freed)
def _find_lru_leaf(self) -> Optional[RadixNode]:
"""扫描找到最优驱逐目标:LRU + 低优先级 + 无活跃引用"""
candidates = []
def dfs(node, depth):
if not node.children: # 叶子
# 叶子且ref_count==0(无人使用)才有资格被驱逐
if node.ref_count == 0:
candidates.append((node, depth))
for child in node.children.values():
dfs(child, depth + 1)
dfs(self.root, 0)
if not candidates:
return None
# 选择 deepest + oldest 的叶子节点
candidates.sort(key=lambda x: (-x[1], x[0].last_access_time))
return candidates[0][0]
def _touch_path(self, node: RadixNode):
"""更新节点及其祖先的access时间"""
node.last_access_time = time.time()
node.ref_count += 1
def _find_parent(self, target: RadixNode) -> Optional[RadixNode]:
"""查找指定节点的父节点"""
def dfs(node):
for child in node.children.values():
if child is target:
return node
result = dfs(child)
if result:
return result
return None
return dfs(self.root)
二、调度层设计:从Cache-hit到Prefill预算
2.1 前缀感知请求调度器
RadixAttention带来的核心调度挑战是:不同的请求因其与共享前缀树的匹配程度不同,其prefill计算量差异极大。一个命中90%前缀的请求只需计算10%的token就可以进入decode阶段,这直接影响排队策略。
class PrefixAwareScheduler:
"""
SGLang调度器核心:结合Radix匹配结果的智能调度
关键设计:
1. 区分"Prefix Hit Rate"将请求分为热/温/冷三档
2. 热请求(高命中率)优先调度以减少其排队时间
3. 冷请求(Miss)在有大量Free Block时才准入
4. Chunked Prefill预算动态计算
"""
def __init__(self, radix_cache: RadixCache, max_running: int = 256,
max_budget: int = 2048):
self.cache = radix_cache
self.waiting_queue: List[Sequence] = [] # 等待队列
self.running: List[Sequence] = [] # 正在运行的序列
self.max_running = max_running
self.chunk_budget = max_budget # 每轮最大新prefill token数
self.cache_hit_total = 0
self.cache_miss_total = 0
def submit_request(self, seq: Sequence):
"""提交新请求:先执行Prefix Match确定Cache命中率"""
prefix_len = len(seq.token_ids)
node, matched, remaining = self.cache.match_prefix(tuple(seq.token_ids))
seq.cache_matched_tokens = matched
seq.cache_hit_rate = matched / prefix_len if prefix_len > 0 else 0
seq.prefill_remaining = len(remaining) # 实际需要prefill的token数
# 维护node引用(防止在Run完成前被GC)
seq.ref_node = node
node.ref_count += 1
self.waiting_queue.append(seq)
self.cache_hit_total += matched
self.cache_miss_total += len(remaining)
def schedule(self) -> List[Sequence]:
"""
每轮的调度决策
策略:
1. 回收已完成序列持有的缓存引用
2. 按 (cache_hit_rate DESC, arrival_time ASC) 排序等待队列
3. 按Chunked Prefill原则分批准入新请求
4. 运行中的请求参与增量Prefill和Decode
"""
# 清理已完成的序列引用
self.running = [s for s in self.running if not s.is_finished()]
for s in self.running:
if s.just_finished:
s.ref_node.ref_count -= 1
# 按前缀命中率降序排序(热请求优先)
self.waiting_queue.sort(
key=lambda s: (-s.cache_hit_rate, s.arrival_time)
)
budget_remaining = self.chunk_budget
admitted = []
for seq in self.waiting_queue[:]:
if len(self.running) >= self.max_running:
break
# 热请求(命中率>70%)有更高准入优先级,消耗budget更少
needed = seq.prefill_remaining
if needed <= budget_remaining:
# 前缀匹配完成后,写入新alloced的KV Blocks
matched, new_blocks = self.cache.insert(
tuple(seq.token_ids), priority=seq.priority
)
seq.alloc_new_blocks(new_blocks)
admitted.append(seq)
budget_remaining -= needed
self.waiting_queue.remove(seq)
return admitted + self.running
2.2 GPU物理块的内存压缩与分层
当Radix Tree驱逐节点时,被释放的KV Cache Block需要既标记为Free又确保不会因TreeNode残留的指针关系导致泄漏。SGLang引入了引用计数 + 惰性回收的双重策略:
class TieredKVStorage:
"""
SGLang的三层KV Cache存储架构
L1: GPU HBM(最快, ~1.5-3TB/s带宽)
L2: CPU DRAM(次快, ~50-100GB/s, 容量大10-100倍)
L3: NVMe SSD(最慢但容量无限,通过CUDA异步传输桥接)
驱逐优先级: L1热点保留, L1冷→L2, L2冷→L3, L3冷→彻底删除
这一设计使得前缀缓存在GPU显存受限时不会直接丢失,
而是降级到CPU内存,在重新命中时异步回迁。
"""
def __init__(self, gpu_pool_size: int, cpu_pool_size: int):
self.gpu_pool = GPUMemPool(gpu_pool_size) # ~100K blocks on A100-80G
self.cpu_pool = CPUMemPool(cpu_pool_size) # ~1M blocks on 256GB DRAM
self.pending_transfers = []
def demote_l1_to_l2(self, node: RadixNode) -> List[int]:
"""将L1(GPU)的KV Block降级到L2(CPU)"""
gpu_blocks = node.block_indices
cpu_blocks = self.cpu_pool.allocate(len(gpu_blocks))
# 异步 GPU→CPU 拷贝
copy_gpu_to_cpu_async(gpu_blocks, cpu_blocks)
self.gpu_pool.free(gpu_blocks)
node.block_indices = cpu_blocks
node.storage_tier = 'L2'
return cpu_blocks
def promote_l2_to_l1(self, node: RadixNode):
"""热路径再次命中时将L2数据提升回L1"""
cpu_blocks = node.block_indices
gpu_blocks = self.gpu_pool.allocate(len(cpu_blocks))
copy_cpu_to_gpu_async(cpu_blocks, gpu_blocks)
node.block_indices = gpu_blocks
node.storage_tier = 'L1'
self.pending_transfers.append((node, gpu_blocks))
async def wait_promotion(self, node: RadixNode):
"""等待异步提升完成,插入流水线避免阻塞调度"""
for n, blocks in self.pending_transfers:
if n is node:
await cuda_sync()
self.pending_transfers.remove((n, blocks))
self.cpu_pool.free(n.original_cpu_blocks)
return blocks
三、监控与生产实践
3.1 核心指标体系
RadixAttention引入的最大运维变化是需要追踪Cache命中率这一全新维度。SGLang内置了一套与Prometheus无缝集成的指标导出器:
# Prometheus告警规则示例 - SGLang RadixAttention监控
groups:
- name: sglang_radix_cache
rules:
# 1. Cache命中率告警:低于30%说明共享前缀利用不足
- alert: RadixCacheHitRateLow
expr: |
(
sum(rate(sglang:cache_hit_tokens_total[5m]))
/
sum(rate(sglang:total_prefill_tokens_total[5m]))
) < 0.30
for: 10m
labels:
severity: warning
annotations:
summary: "Radix Cache命中率低于30%, 存在大量重复计算"
description: "当前命中率 {{ $value | humanizePercentage }}, 建议检查System Prompt一致性"
# 2. Block利用率告警:接近95%时驱逐风暴即将发生
- alert: KVBlockPressure
expr: |
(
sglang:kv_blocks_used_total
/
sglang:kv_blocks_total
) > 0.95
for: 5m
labels:
severity: critical
annotations:
summary: "KV显存压力过高, 频繁驱逐会降低缓存效率"
# 3. Prefill-Overhead比率告警:新请求排队时间异常
- alert: PrefillQueueBacklog
expr: |
histogram_quantile(0.99,
sum(rate(sglang:time_to_first_token_seconds_bucket[5m])) by (le)
) > 2.0
for: 5m
labels:
severity: warning
annotations:
summary: "P99首Token延迟超过2秒, 需扩容或限速"
3.2 基准测试:前缀复用带来的吞吐收益
为了量化RadixAttention的收益,我们对比了三种场景下(vLLM无复用、vLLM+分块前填充、SGLang RadixAttention)的推理吞吐量:
# 测试配置:Llama-2-7B on A100-80GB
# 共享System Prompt: 2000 tokens
# 用户Query: 200 tokens
# 输出长度: 512 tokens
# 并发数: 256
# 场景1: vLLM 原始模式(无前缀复用)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-2-7b-chat-hf \
--max-model-len 4096
# 结果: 吞吐 2.1k tokens/s, TTFT p50=380ms
# 场景2: vLLM + Chunked Prefill
# 启用Chunked Prefill以减少大请求阻塞
# 场景3: SGLang + RadixAttention
python -m sglang.launch_server \
--model-path meta-llama/Llama-2-7b-chat-hf \
--enable-radix-cache \
--mem-fraction-static 0.85
# 结果: 吞吐 8.7k tokens/s, TTFT p50=45ms
# 吞吐提升: 4.1x
# TTFT降低: 88%
# Cache命中率: 92%
四、分布式场景的挑战与解决方案
4.1 多节点间的前缀一致性
在单节点场景下,RadixTree维护在本地内存中即可保证一致性。但在分布式Deployment(多GPU节点、Prefill-Decode分离部署)场景下,问题变得复杂:
class DistributedRadixCoordinator:
"""
分布式Radix Cache协调器
生产级Serving的关键挑战:
- Worker B的Decode请求可能需要在Worker A的Prefill之后利用前缀
- Prefill-Decode分离部署时,Prefill节点的Cache对Decode节点不可见
解决方案:
1. 前缀路由:相同前缀的请求路由到相同Prefill节点
2. KV Cache远程访问:Decode节点按需从Prefill节点Pull KV Blocks(高RDMA带宽下可行)
3. Cache状态通过Gossip协议在节点间同步
"""
def __init__(self, consistent_hash_ring: HashRing):
self.hash_ring = consistent_hash_ring
self.local_cache = RadixCache()
self.remote_stubs: Dict[str, KVStub] = {} # worker_id → RPC stub
def route_prefill(self, request) -> str:
"""
前缀哈希路由:对请求前缀做一致性哈希,
使得相同前缀的请求总是路由到同一Prefill节点
以最大化该节点上的Cache命中率
"""
# 取前N个token(通常是System Prompt的一部分)作为路由key
prefix_key = tuple(request.token_ids[:32]) # 32 tokens足够区分不同prompt类型
target_worker = self.hash_ring.get_node(str(prefix_key))
return target_worker
def cross_node_kv_transfer(self, src_node: str, block_ids: List[int],
priority: str = 'high') -> KVBlockHandle:
"""
跨节点KV Cache传输
由于KV Cache Block的典型大小:
- Llama-2-7B: 每block约 2 * n_layer * head_dim * block_size * sizeof(fp16)
= 2 * 32 * 128 * 16 * 2B = 256KB
- 通过100Gbps RDMA传输: ~2.2ms latency
- 与普通Prefill的延迟相比可忽略
"""
# 实现KV Block的RDMA单边读取或RPC拉取
stub = self.remote_stubs[src_node]
handle = stub.export_blocks(block_ids)
buf = self.cuda_allocator.allocate(len(block_ids) * BLOCK_SIZE)
rdma_read(buf, handle.addr, handle.size)
return KVBlockHandle(buf, block_ids)
4.2 LMCache与Mooncake:KV Cache分层的下一代
2025年涌现出一系列扩展RadixAttention思想的方案。LMCache(由UC Berkeley团队开发)将KV Cache分层扩展到了CPU内存和NVMe SSD,实现跨提示词、跨对话的KV缓存。而Mooncake(月之暗面)则将KV Cache作为一等公民设计为分离式推理架构中的独立存储层。
class LMCacheTieredEngine:
"""
LMCache的分层缓存引擎
与SGLang内置的分层机制不同,LMCache将KV Cache视为
"可迁移的状态"而非"与Sequence绑定的存储"。
关键创新:
1. 支持KV Cache的异步写出与延迟加载
2. Chunk级粒度管理(非Block级),适配不同prompt长度
3. 支持跨模型迁移(将Llama的KV转换为Mistral的KV)
"""
def __init__(self):
self.gpu_cache = GPUKVStore(capacity_gb=60)
self.cpu_cache = CPUKVStore(capacity_gb=200)
self.disk_cache = DiskKVStore(path='/nvme/lmcache', capacity_gb=2000)
self.prefetch_queue = asyncio.Queue()
async def lookup(self, prompt: str) -> Optional[KVLocation]:
"""
多级缓存查找(类似CPU Cache的L1→L2→L3→Memory路径)
"""
key = hash(prompt)
# L1: GPU Hit
hit = self.gpu_cache.get(key)
if hit:
return KVLocation(tier='gpu', data=hit, latency_ms=0.01)
# L2: CPU Hit → 异步回迁GPU
hit = self.cpu_cache.get(key)
if hit:
asyncio.create_task(self._promote_to_gpu(key, hit))
return KVLocation(tier='cpu', data=hit, latency_ms=0.3)
# L3: Disk Hit → 异步加载到CPU→GPU
hit = self.disk_cache.get(key)
if hit:
asyncio.create_task(self._load_disk_to_cpu_to_gpu(key, hit))
return KVLocation(tier='disk', data=hit, latency_ms=5.0)
return None # Cache Miss
async def _promote_to_gpu(self, key, cpu_data):
"""CPU→GPU异步提升,不阻塞调度"""
gpu_buf = self.gpu_cache.allocate_slot(key)
await copy_cpu_to_gpu(cpu_data, gpu_buf)
self.gpu_cache.commit(key, gpu_buf)
五、前沿方向与开放问题
RadixAttention并非终点,围绕KV Cache复用架构仍有一系列开放问题:
问题一:自适应前缀粒度 — 固定Block Size在短前缀场景下浪费显存,在长前缀场景下导致频繁驱逐。动态Block大小(如Pow-of-2分级)或基于标记的块(Token-boundary aware blocks)是新方向。
问题二:Attention计算复用 — RadixAttention仅复用了KV存储的显存,但对新请求而言,prefix部分的Attention计算仍然需要执行。研究人员提出通过增量Prefix Attention的方案(如Prompt Cache中的Flash Attention改造),将prefix部分的Attention结果也一并缓存在GPU L2 Cache中。
问题三:MoE模型的专家KV Cache — 在Mixture-of-Experts模型中,不同token激活不同专家,导致KV Cache的"共享模式"从连续前缀变为expert assignment pattern。如何高效缓存和复用MoE模型中不同专家通道的KV状态,是当前v0.5版本正在探索的方向。
问题四:分离式推理下的KV Cache一致性协议 — Prefill节点消费完Prefix后将KV Cache传递给Decode节点,这本质上是一个分布式状态传输问题。当网络延迟和Prefill时间相当时,传统的一次性传输模型退化为流水线模型,需要更精细的"边传边算"调度。
总结
RadixAttention的成功正印证了计算机系统设计的经典原理:当应用的访存模式呈现明显的时间局部性(同一前缀在短时间内被多次使用)和空间局部性(相邻token形成边的关系),数据结构层面的专门优化可以带来数倍的端到端收益。
从vLLM的PagedAttention解决碎片化,到SGLang的RadixAttention解决重复计算,再到LMCache和Mooncake解决跨节点分层——KV Cache管理正在从"可用"走向"高效"。对于希望在2026年构建生产级LLM Serving基础设施的工程师而言,理解Radix Tree的数据结构特性、掌握分层Cache的命中率调优方法论,已经成为一项必备技能。
最后,一个值得深思的问题是:KV Cache复用与KV Cache压缩(如H2O、StreamingLLM的稀疏注意力方案)是否是对立路线?实际上并非如此——RadixAttention解决的是跨请求复用,而H2O解决的是单请求内的历史丢弃,二者正交且可以协同。这或许是下一代LLM推理框架的另一个突破口。

发表评论 取消回复