news 2026/9/13 8:09:18

前端工程师如何用Redis+BM25构建Agent记忆模块

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端工程师如何用Redis+BM25构建Agent记忆模块

1. 为什么前端工程师突然开始写“记忆模块”?——这不是转行,是能力跃迁的必然路径

最近在几个前端技术群和Agent开发社区里,总能看到类似的问题:“前端怎么切入Agent开发?”“学完React还要学LangChain吗?”“我连Redis都没部署过,能搞Agent吗?”——这些问题背后,藏着一个被严重低估的事实:前端工程师,其实是Agent系统中最天然的记忆架构师。不是因为我们会写组件,而是因为我们每天都在和“状态”打交道:表单输入、滚动位置、用户偏好、页面缓存、本地存储……这些全都是“记忆”的原始形态。当Agent从玩具级demo走向真实业务场景,最卡脖子的从来不是大模型调用,而是如何让AI记住你昨天问过什么、上周改过哪条配置、上个月审批过哪个流程。而这个“记住”,不是靠LLM硬记,而是靠一套可落地、可调试、可运维的记忆模块。

我自研这个记忆模块的起点,就来自一次真实的项目踩坑。当时给某政务服务平台做AI助手,用户第一次问“我的社保缴费记录在哪查”,系统能精准返回;但用户紧接着问“那上个月的呢”,AI直接懵了——它不记得“上个月”指的是谁的、哪个月、上下文里有没有时间锚点。后端同学甩来一句“你前端自己存一下呗”,我一愣:前端存?存哪?localStorage?那服务端怎么同步?存sessionStorage?那换台电脑就没了。最后我们硬生生用Redis搭了个轻量级记忆层,把用户ID+会话ID+时间戳作为key,把对话摘要、关键实体、操作意图结构化存进去。上线后发现,90%的上下文断裂问题消失了。那一刻我意识到:前端不是要“转行”去写Agent,而是要把过去十年积累的状态管理经验,升维成Agent系统的记忆基础设施。这个模块不依赖任何Agent框架,不绑定特定大模型,核心就是三件事:存得准、找得快、删得稳。用的是最朴素的Redis数据结构,配的是BM25这种老派但靠谱的文本检索算法,目标很实在:让AI在真实业务里,像人一样“记得住事”。

2. 记忆模块设计思路:为什么不用Vector Store,而选Redis+BM25?

2.1 真实业务场景下的记忆需求,和论文里的根本不是一回事

很多刚接触Agent开发的前端朋友,第一反应是“上向量库”。毕竟LangChain文档里全是Chroma、Pinecone、Weaviate。但我在政务、金融、SaaS三个行业落地过7个Agent项目后,发现真实世界的记忆需求有四个硬约束:

  • 低延迟强一致性:用户问“我上笔贷款审批到哪步了”,响应必须<300ms,且不能出现“刚提交的申请查不到”的情况;
  • 精确语义锚定:不是模糊匹配“贷款”,而是精准定位“张三_20240512_工行房贷_预审中”这条记录;
  • 冷热分离明确:用户近3次对话要毫秒级召回,3年前的流水记录可以接受秒级延迟;
  • 运维可见性高:运维同学能直接用Redis Desktop Manager看到某用户的记忆快照,而不是面对一堆embedding向量发呆。

Vector Store在第一个约束上就掉链子。以Chroma为例,单次查询平均耗时800ms+(实测16核32G服务器),且受索引重建影响波动极大。更致命的是,它天然丢失结构化信息——你存一条“张三申请房贷”,向量化后变成[0.23, -1.45, ……],但运营同学想查“所有预审中的房贷申请”,Vector Store根本没法做条件过滤。而Redis原生支持Hash、Sorted Set、Stream多种结构,配合Lua脚本,能把“存-查-删-过期”全链路压进10ms内。

2.2 Redis不是“凑合用”,而是为记忆场景量身定制的数据库

有人质疑:“Redis不是内存数据库吗?数据不持久?”——这是对Redis最大的误解。从6.0版本起,Redis的AOF(Append Only File)+RDB混合持久化已足够可靠。我们线上环境采用AOF everysec + RDB daily snapshot策略,实测单节点QPS 12万时,数据丢失窗口<1秒(符合金融级SLA)。更重要的是,Redis的数据结构和记忆场景完美咬合:

  • Hash结构存用户记忆快照HSET mem:uid:12345 session:abc123 "{'last_intent':'loan_apply','entities':['张三','工行'],'timestamp':1715234567}—— 天然支持字段级更新,比JSON字符串解析快3倍;
  • Sorted Set存时间线记忆ZADD mem:timeline:12345 1715234567 "loan_apply_abc123"—— 按时间戳自动排序,ZRANGEBYSCORE秒级获取最近N条;
  • Stream存事件溯源记忆XADD mem:events:12345 * event_type=loan_status_change status=pre_approved—— 完整保留状态变更过程,审计合规零成本。

对比MongoDB或PostgreSQL,Redis少了SQL解析开销,多了原子操作保障。比如“更新用户最后意图+追加时间线+触发事件”这三步,在PostgreSQL里要事务+锁+索引维护,而在Redis里一个Lua脚本搞定:

-- memory_update.lua local uid = KEYS[1] local session_id = ARGV[1] local intent = ARGV[2] local timestamp = ARGV[3] -- 更新Hash快照 redis.call('HSET', 'mem:uid:'..uid, 'session:'..session_id, '{"last_intent":"'..intent..'", "timestamp":'..timestamp..'}') -- 追加Sorted Set时间线 redis.call('ZADD', 'mem:timeline:'..uid, timestamp, session_id..':'..intent) -- 触发事件流 redis.call('XADD', 'mem:events:'..uid, '*', 'event_type', 'memory_update', 'intent', intent, 'ts', timestamp) return 1

这段脚本执行时间稳定在0.8ms以内,且全程原子性——这才是Agent记忆模块需要的“肌肉反应”。

2.3 BM25不是过时技术,而是对抗LLM幻觉的最后防线

为什么不用Embedding相似度,而选BM25?答案很简单:BM25能告诉你“为什么匹配”,Embedding只能告诉你“大概像”

举个真实案例:用户问“我的公积金提取进度”,Embedding可能把“公积金缴存证明”也召回(向量空间里“提取”和“缴存”距离很近),但BM25会严格计算词频(TF)和逆文档频率(IDF):

  • “提取”在公积金文档中出现频率高 → TF值高;
  • “提取”在整个知识库中稀有 → IDF值高;
  • “缴存”虽高频但IDF低 → 权重被大幅压缩。

我们用Python实现的轻量BM25(基于rank_bm25库),在10万条政务文档测试集上,准确率比OpenAI text-embedding-ada-002高12.7%(实测数据),且响应时间稳定在15ms。更重要的是,BM25结果可解释:返回每条匹配的scorematched_terms,前端能直接展示“匹配依据:提取(0.82)、公积金(0.91)、进度(0.75)”,用户一眼看懂AI为什么这么答——这在政务、医疗等强信任场景里,比“AI觉得像”重要十倍。

3. 核心细节解析:从零搭建可落地的记忆模块

3.1 Redis环境准备:避开Windows和Docker的典型陷阱

很多前端同学卡在第一步:Redis安装。这里必须强调两个血泪教训:

  • Windows环境慎用MSI安装包:官方Redis for Windows已停止维护,最新版(6.2.6)在Win11上存在fork()模拟缺陷,导致BGSAVE失败,内存泄漏。正确做法是:用WSL2运行原生Linux版Redis(Ubuntu 22.04),命令极简:

    # WSL2内执行 sudo apt update && sudo apt install redis-server sudo systemctl enable redis-server # 配置文件在 /etc/redis/redis.conf,重点改这两行: # bind 127.0.0.1 ::1 → 改为 bind 0.0.0.0(前端需跨域访问) # requirepass your_strong_password → 设强密码(至少12位含大小写数字符号)
  • Docker部署别只拉官方镜像redis:alpine镜像缺少redis-cli调试工具,redis:latest又太大。我们生产环境用自定义Dockerfile:

    FROM redis:7.2-alpine RUN apk add --no-cache redis-cli && \ mkdir -p /usr/local/etc/redis && \ cp /usr/local/etc/redis/redis.conf /usr/local/etc/redis/redis.conf.bak COPY redis.conf /usr/local/etc/redis/redis.conf CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]

    关键参数redis.conf设置:

    # 内存策略:LRU淘汰,避免OOM maxmemory 2gb maxmemory-policy allkeys-lru # 持久化:AOF每秒刷盘+RDB每日快照 appendonly yes appendfsync everysec save 86400 1 # 网络:绑定所有IP,但限制端口暴露 bind 0.0.0.0 protected-mode no port 6379

提示:前端调用Redis必须走后端代理!禁止前端直连Redis。我们用Node.js Express写了个轻量代理层,只开放GET /memory/:uidPOST /memory/:uid两个接口,所有请求经JWT校验+IP白名单+速率限制(100req/min),Redis连接池复用,避免连接爆炸。

3.2 记忆数据建模:用前端思维设计Schema

前端最擅长的不是写SQL,而是设计State。记忆模块的Schema设计,我完全套用React状态管理逻辑:

字段名类型示例值前端类比说明
user_idstring"u_8a7b2c"useState的key用户唯一标识,建议用业务系统UID,非随机UUID
session_idstring"s_abcd1234"useReducer的action.type会话ID,WebSocket连接建立时生成,超时30分钟自动失效
last_intentstring"loan_apply"组件props.intent最近一次明确意图,用于快速路由
entitiesarray["张三","工行"]useMemo缓存的实体数组从对话中抽取出的关键名词,JSON序列化存
context_windowarray[{"role":"user","content":"..."},{"role":"assistant","content":"..."}]useRef保存的对话历史仅存最近5轮,每条<500字符,避免膨胀
timestampnumber1715234567Date.now()Unix时间戳,用于排序和过期

这个Schema的妙处在于:所有字段都可被前端直接消费。比如last_intent能驱动UI状态切换(贷款页/查询页/帮助页),entities可渲染高亮标签,context_window直接喂给LLM——无需后端二次转换。我们甚至用这个Schema生成TypeScript接口:

interface MemoryItem { user_id: string; session_id: string; last_intent: string; entities: string[]; context_window: { role: 'user' | 'assistant'; content: string }[]; timestamp: number; }

前端调用fetch('/api/memory/u_8a7b2c')拿到的就是强类型对象,VS Code自动补全,编译期报错,比写JSON Schema省心十倍。

3.3 BM25检索引擎:手写比调包更可控

网上教程全教你怎么用rank_bm25,但没人告诉你:默认的BM25实现对中文分词极其不友好rank_bm25内置分词器按空格切分,中文根本没空格!我们实测用jieba分词预处理后,召回率提升37%。完整流程如下:

  1. 预处理管道(Python后端):

    import jieba from rank_bm25 import BM25Okapi def preprocess_text(text: str) -> list: # 移除标点、转小写、jieba精确分词 import re cleaned = re.sub(r'[^\w\u4e00-\u9fff]+', ' ', text) return list(jieba.cut_for_search(cleaned.lower())) # 构建语料库(从Redis读取所有用户记忆摘要) corpus = [] for uid in redis_client.keys("mem:uid:*"): data = redis_client.hgetall(uid) for session_data in data.values(): try: obj = json.loads(session_data) # 摘要 = last_intent + entities + context_window首句 summary = f"{obj.get('last_intent', '')} {' '.join(obj.get('entities', []))}" if obj.get('context_window'): summary += " " + obj['context_window'][0]['content'][:50] corpus.append(preprocess_text(summary)) except: continue bm25 = BM25Okapi(corpus)
  2. 检索接口(FastAPI):

    @app.post("/search/memory") async def search_memory(query: str): tokenized_query = preprocess_text(query) scores = bm25.get_scores(tokenized_query) top_n = np.argsort(scores)[::-1][:5] # 取Top5 results = [] for idx in top_n: if scores[idx] > 0.1: # 阈值过滤低相关 # 从corpus反查原始记忆ID uid = list(redis_client.keys("mem:uid:*"))[idx // 100] # 简化示意 results.append({ "uid": uid, "score": float(scores[idx]), "matched_terms": [t for t in tokenized_query if t in corpus[idx]] }) return {"results": results}

实操心得:BM25的k1b参数必须调优。我们线上用k1=1.5, b=0.75(默认是1.5, 0.75),但实测政务文本b=0.5效果更好——因为政务术语重复率高,降低文档长度惩罚能提升长句匹配。这个参数没玄学,用100条真实query跑AB测试,看准确率变化就行。

4. 实操过程:从初始化到上线的完整链路

4.1 初始化:三步完成记忆模块接入

整个接入过程控制在20分钟内,不需要改现有代码架构:

第一步:环境变量注入
.env文件里加两行:

REDIS_URL=redis://:your_password@localhost:6379/0 MEMORY_TTL=3600 # 记忆默认过期时间(秒)

第二步:初始化Redis客户端(Node.js后端)
ioredis替代原生redis,支持连接池和自动重连:

import Redis from 'ioredis'; const redisClient = new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, db: 0, maxRetriesPerRequest: null, // 关键!禁用单请求重试,避免幂等性问题 enableReadyCheck: false, // 关键!跳过ready检查,启动更快 }); // 连接池监控 redisClient.on('connect', () => console.log('Redis connected')); redisClient.on('error', (err) => console.error('Redis error:', err));

第三步:封装记忆操作API
创建memoryService.ts,暴露四个原子方法:

export const memoryService = { // 存:用户记忆快照 async setMemory(userId: string, sessionId: string, data: MemoryItem) { await redisClient.hset(`mem:uid:${userId}`, `session:${sessionId}`, JSON.stringify(data)); await redisClient.expire(`mem:uid:${userId}`, parseInt(process.env.MEMORY_TTL || '3600')); }, // 查:按用户ID获取所有会话 async getMemoryByUser(userId: string): Promise<MemoryItem[]> { const sessions = await redisClient.hgetall(`mem:uid:${userId}`); return Object.values(sessions).map(s => JSON.parse(s)); }, // 检索:BM25搜索 async searchMemory(query: string, userId?: string) { const response = await fetch(`${process.env.BM25_API}/search/memory`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, userId }) }); return response.json(); }, // 清:用户级清理(如注销时) async clearMemory(userId: string) { await redisClient.del(`mem:uid:${userId}`); } };

注意:setMemoryexpire命令必须紧跟hset之后,否则可能存入后立即过期。Redis Pipeline能保证原子性,但我们用简单顺序调用已足够——实测并发1000QPS下,过期失败率<0.001%。

4.2 Agent集成:在LLM调用前插入记忆钩子

记忆模块的价值,体现在LLM请求发出前的最后一刻。我们用Express中间件实现无缝注入:

// memoryMiddleware.ts export const injectMemoryContext = async (req: Request, res: Response, next: NextFunction) => { const { userId, sessionId } = req.headers; if (!userId || !sessionId) return next(); try { // 1. 获取用户最近3次会话记忆 const memories = await memoryService.getMemoryByUser(userId as string); const recentMemories = memories .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 3); // 2. 构建记忆上下文(精简版) const memoryContext = recentMemories.map(m => `【${new Date(m.timestamp * 1000).toLocaleDateString()}】${m.last_intent},涉及:${m.entities.join('、')}` ).join('\n'); // 3. 注入到请求体(适配OpenAI格式) if (req.body.messages) { req.body.messages.unshift({ role: 'system', content: `用户历史记忆:${memoryContext || '无近期记忆'}。请基于此回答,不要编造。` }); } next(); } catch (err) { console.warn('Memory injection failed:', err); next(); // 失败不阻断,降级为无记忆模式 } }; // 使用 app.post('/v1/chat/completions', injectMemoryContext, openaiProxyHandler);

这个设计的精妙在于:不改变LLM API协议,不增加前端负担,所有记忆增强对业务透明。前端还是调/v1/chat/completions,后端自动塞入记忆上下文。我们上线后监测发现,带记忆的请求平均token消耗只增加120个(约$0.0003),但任务完成率提升28%(统计10万次对话)。

4.3 上线验证:用真实对话流做压力测试

别信单元测试,用真实流量验证。我们写了三组测试用例:

Case 1:时间锚点连续问

  • 用户问:“我的公积金账户余额多少?” → 系统查出余额:¥12,345.67
  • 紧接着问:“那上个月呢?” → 记忆模块识别上个月指代前一个查询的时间点,返回2024年4月余额:¥11,987.23
    验证点:时间相对解析 + 记忆关联

Case 2:实体消歧

  • 用户问:“张三的贷款进度?” → 记忆模块返回张三_工行房贷_预审中
  • 紧接着问:“李四的呢?” → 模块切换到李四_建行车贷_已放款
    验证点:多用户隔离 + 实体绑定

Case 3:故障降级

  • 手动停掉Redis服务 → 所有getMemoryByUser返回空数组
  • 系统日志记录WARN: Redis unavailable, fallback to no-memory mode
  • 对话继续,只是不带历史上下文
    验证点:优雅降级 + 监控告警

用JMeter模拟1000并发用户,持续压测1小时,结果:

  • Redis CPU峰值<45%,内存占用稳定在1.2GB(2GB上限)
  • 平均响应时间127ms(P95 210ms)
  • 错误率0.02%(全为网络超时,非Redis错误)

踩过的坑:最初用redisClient.hgetall()一次性读取所有会话,当用户记忆超100条时,单次响应达2MB,拖慢整个链路。改成hscan分页读取(每次10条),内存占用下降83%,这才是生产级实践。

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

5.1 Redis连接池爆满:不是配置问题,是忘记释放

现象:前端调用变慢,后端日志疯狂打印Error: Connection is closed
根因:前端频繁创建新连接,未复用。
解决方案:

  • 后端必须用连接池(ioredis默认开启,redis需手动配置)
  • 前端调用必须走统一API网关,禁止直连Redis
  • 关键检查:redisClient.status应始终为"ready",若为"connecting""close",立刻重启服务

5.2 BM25检索结果为空:分词器没切对,不是算法问题

现象:搜“公积金提取”,返回空列表。
排查步骤:

  1. 登录Redis CLI,HGETALL mem:uid:xxx看原始数据是否存入
  2. redis-cli --raw hget mem:uid:xxx session:abc123确认JSON格式正确
  3. 在Python shell里运行preprocess_text("公积金提取"),看输出是不是['公积金', '提取']
  4. 检查语料库是否为空:len(corpus),若为0说明Redis没读到数据

独家技巧:在BM25检索前加日志,打印tokenized_querycorpus[0],对比词项是否匹配。我们曾发现政务系统里“公积金”被录入为“住房公积金”,分词后变成['住房', '公积金'],和查询词['公积金']不重合——加同义词映射表解决。

5.3 记忆过期不一致:TTL设置位置错了

现象:用户说“我刚存的记录怎么没了?”,查Redis发现ttl显示-1(永不过期)。
原因:EXPIRE命令必须在HSET之后立即执行,中间不能有其他命令。
正确姿势:

// ❌ 错误:先HSET再EXPIRE,中间可能有其他操作 await redisClient.hset(key, field, value); await redisClient.expire(key, ttl); // 可能key已被删 // ✅ 正确:用Pipeline保证原子性 const pipeline = redisClient.pipeline(); pipeline.hset(key, field, value); pipeline.expire(key, ttl); await pipeline.exec();

5.4 前端跨域失败:不是CORS配置,是Redis绑定错了

现象:浏览器控制台报net::ERR_CONNECTION_REFUSED
真相:前端尝试直连http://localhost:6379(Redis端口),但Redis默认只监听127.0.0.1,不响应外部请求。
修复:

  • 修改redis.confbind 127.0.0.1 ::1bind 0.0.0.0
  • 重启Redis:sudo systemctl restart redis-server
  • 但必须加防火墙限制sudo ufw allow from 192.168.1.100 to any port 6379(只允信任IP)

5.5 记忆污染:用户A的数据出现在用户B的检索结果里

现象:张三搜“我的贷款”,返回李四的贷款记录。
根因:BM25语料库构建时,没按用户隔离,把所有用户记忆混在一起索引。
修复方案:

  • 方案1(推荐):BM25索引按用户分片,每个用户独立语料库,内存占用略高但绝对安全
  • 方案2:检索时加用户ID过滤,searchMemory(query, userId),在BM25结果里二次筛选
  • 方案3(生产首选):用Redis Search模块,建FT.CREATE idx_user_mem ON HASH PREFIX 1 "mem:uid:" SCHEMA user_id TAG session_id TAG last_intent TEXT entities TEXT,原生支持过滤

实操心得:我们最终选方案3。Redis Search是Redis 6.0+内置模块,无需额外服务,FT.SEARCH idx_user_mem "@user_id:{u_8a7b2c} @last_intent:{loan_apply}"毫秒级返回,还支持拼音搜索(PHONETIC参数),解决“张三”搜“张san”问题。

6. 前端工程师的Agent进阶路径:从记忆模块到智能体基建

写完这个记忆模块,我彻底明白了:前端转Agent开发,根本不是学新东西,而是把老手艺用在新战场。我们天天写的useState,本质是单机状态记忆;localStorage,是持久化记忆;SW Cache,是资源记忆;WebSocket,是实时记忆通道——把这些能力组合、升维、工程化,就是Agent的记忆系统。

后续我正推进三个方向:

  • 记忆可视化:用ECharts画用户记忆热力图,横轴时间、纵轴意图类型,运营同学一眼看出高频问题;
  • 记忆成本监控:每条记忆打标(cost: $0.0001),对接财务系统,让AI成本可计量;
  • 记忆联邦学习:在边缘设备(如政务自助终端)本地存记忆,定期加密上传,既保隐私又提体验。

最后分享个小技巧:下次面试被问“前端怎么学Agent”,别背八股文。打开你的Chrome DevTools,Console里敲localStorage,然后说:“你看,我每天都在写记忆模块,只是以前存按钮状态,现在存用户意图——这就是Agent开发的起点。”

这个模块的代码已开源在GitHub(frontend-to-agent/memory-core),没有一行魔法,全是可调试、可监控、可替换的务实代码。真正的技术深度,不在炫技的向量库,而在让AI记得住人、认得出你、守得住约——而这,恰是前端最拿手的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 8:09:14

2026年学术论文AI检测与降AI率工具全解析

1. 论文AI率检测的现状与挑战2026年的学术圈正在经历一场前所未有的技术变革风暴。去年某高校爆出研究生论文AI生成率高达99%的新闻&#xff0c;直接导致该生被取消学位资格。这件事像一颗深水炸弹&#xff0c;彻底改变了高校对学术论文的审核标准。现在国内主流高校普遍采用AI…

作者头像 李华
网站建设 2026/9/13 8:08:03

Elasticsearch索引原理:深入理解倒排索引、Lucene架构与段合并机制

Elasticsearch索引原理&#xff1a;深入理解倒排索引、Lucene架构与段合并机制 本文深入解析Elasticsearch核心索引原理&#xff0c;详细阐述倒排索引的工作机制、Lucene的数据结构设计以及段合并策略的实现原理。通过理解这些底层技术&#xff0c;开发者能够优化索引性能&…

作者头像 李华
网站建设 2026/9/13 8:07:26

如何将 MCP Server 的工具接入 AI SDK 并选择 HTTP 或 stdio 传输

如何将 MCP Server 的工具接入 AI SDK 并选择 HTTP 或 stdio 传输 【免费下载链接】ai The AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents 项目地址: https://gitc…

作者头像 李华
网站建设 2026/9/13 8:06:40

如何端到端运行 machine-learning-for-trading 的 ETF 案例研究流水线

如何端到端运行 machine-learning-for-trading 的 ETF 案例研究流水线 【免费下载链接】machine-learning-for-trading Code for Machine Learning for Trading, 3rd edition — from data sourcing to live execution. 项目地址: https://gitcode.com/GitHub_Trending/ma/ma…

作者头像 李华
网站建设 2026/9/13 8:06:30

UE5中NavMesh与碰撞体偏移导致AI寻路异常的定位与修复

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华