从 RAG 到 Agentic RAG:固定流程的五个毛病,和把它拆成一张图

公司内部的 Agent 基本都要用 RAG。LLM 会思考,但它不知道你们公司的文档------所以得先把文档检索出来,塞进 prompt。

问题是,这套「检索 → 生成」的流程太固定了。固定就意味着死板,而死板在真实问答里有五个绕不过去的坑。

这篇用一个《天龙八部》问答助手的四个版本,把这五个坑和对应的解法讲清楚:从最朴素的 RAG,一路演进到会路由、拆解、评估、补充的 Agentic RAG。


一、先说清楚:为什么需要 RAG

面试常问的第一个问题就是「你用的什么向量数据库」。常见答案:

向量库 常见搭配
Milvus Node.js / 自建
Qdrant Python
Pinecone 托管服务
pgvector 已有 Postgres,不想加组件

选型先放一边,为什么要 RAG 才是根本:

LLM 能思考,但它不知道公司内部的文档。

把私有文档喂给模型有两条路:一是微调(贵、慢、改了文档还得重训),二是 RAG------把相关文档检索出来,拼进 prompt 里让它带着证据回答

RAG 的标准流程就两步:

复制代码
用户问题 → 向量检索 → 拼进 prompt → 生成回答

简单、直接、能跑通。但恰恰因为太固定,它在真实场景里会出问题。


二、基线版本:一条直线的 RAG

先看最简单能跑的形态(src/naive-rag.mjs):

js 复制代码
const GraphState = Annotation.Root({
  question: Annotation,     // 问题
  k: Annotation,            // 检索数量
  documents: Annotation,    // 检索到的文档
  generation: Annotation    // 生成的内容
});

const graph = new StateGraph(GraphState)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile();

画出来就是一条直线:

%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first retrieve(retrieve) generate(generate) __end__([<p>__end__</p>]):::last __start__ --> retrieve; generate --> __end__; retrieve --> generate; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

检索部分用 Milvus + HNSW 索引:

js 复制代码
vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName: "ebook_collection",
  url: "localhost:19530",
  textField: "content",
  primaryField: "id",
  vectorField: "vector",
  indexCreateOptions: {
    metric_type: "COSINE",
    index_type: "HNSW",              // 多层近邻图向量索引
    param: { M: 16, efConstruction: 200 },
    search_params: { ef: 64 }
  }
});

生成节点把召回片段拼成上下文,再流式输出:

js 复制代码
const generateNode = async (state) => {
  const context = state.documents
    .map((item, i) => `[片段 ${i+1}]
    章节: 第 ${item.chapter_num}章
    内容:${item.content}`)
    .join("\n\n----------\n\n");

  const prompt = `
  你是一个专业的《天龙八部》小说助手...
  请根据以下《天龙八部》小说片段内容回答问题:
  ${context}
  用户问题:${state.question}
  ...`;

  let generation = "";
  const stream = await model.stream(prompt);
  for await (const chunk of stream) { /* 流式打印 */ }
  return { generation };
}

这就是基线。 下面五个毛病,都是在这个基线上暴露出来的。


三、毛病一:所有问题都走检索

1 + 1 = ?

这个问题需要检索《天龙八部》吗?显然不需要。但基线版本会老老实实去向量库捞 5 个片段,然后把这些毫不相关的上下文塞进 prompt。

代价是双重的:

  • 浪费资源:一次 embedding 调用 + 一次向量检索 + 5 个片段的 token;
  • 污染回答:无关片段进了 prompt,就是纯粹的干扰项。

解法:查询路由

在检索之前加一个路由节点 ,用 LLM 判断问题该走哪条路(src/rag-query-router.mjs):

js 复制代码
const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]),
  reason: z.string()
});

const routeQuestionNode = async (state) => {
  const router = model.withStructuredOutput(RouteSchema);   // 结构化输出约束
  const route = await router.invoke(`
  你是问答路由器,请判断用户问题是否需要外部检索。

  规则:
  - simple: 常识问答、简短定义、无需特定小说细节即可回答。
  - complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。

  用户问题: ${state.question}
  `);

  console.log(`路由策略:${route.strategy} ${route.reason}`);
  return { strategy: route.strategy, routeReason: route.reason };
}

然后用条件边分流:

js 复制代码
const decideNext = (state) => (state.strategy === 'simple' ? "direct_answer" : "retrieve")

const graph = new StateGraph(GraphState)
  .addNode("route_question", routeQuestionNode)
  .addNode("direct_answer", directAnswerNode)     // 直接答
  .addNode("retrieve", retrieveNode)              // 走检索
  .addNode("rag_generate", generateNode)
  .addEdge(START, "route_question")
  .addConditionalEdges("route_question", decideNext, {
    direct_answer: "direct_answer",
    retrieve: "retrieve"
  })
  .addEdge("retrieve", "rag_generate")
  .addEdge("direct_answer", END)
  .addEdge("rag_generate", END)
  .compile();

图从一条直线变成了一个分叉

%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first route_question(route_question) direct_answer(direct_answer) retrieve(retrieve) rag_generate(rag_generate) __end__([<p>__end__</p>]):::last __start__ --> route_question; direct_answer --> __end__; rag_generate --> __end__; retrieve --> rag_generate; route_question -.-> direct_answer; route_question -.-> retrieve; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

有个细节值得注意:路由结果用 withStructuredOutput + zod 枚举 来约束,而不是让模型自由输出一段话再解析。这样 strategy 只会是 simplecomplex,不会出现「可能吧」「大概属于复杂问题」这种没法用 if 判断的返回值------用自然语言做控制流,是个隐蔽的坑


四、毛病二:多步检索的问题答不了

这是五个毛病里最硬的一个。看这个问题:

《天龙八部》中【四大恶人】排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?

它其实是一条推理链

复制代码
四大恶人 → 排行第二的是谁 → 叶二娘
叶二娘的儿子 → 虚竹
虚竹的生父 → 揭晓前是什么身份 → 少林寺方丈玄慈

要答对,得连着查三次。但基线版本会把整句话一次性做向量检索------一个包含两跳关系的复合问句,它的 embedding 和任何一个原文片段都不像,召回结果自然一塌糊涂。

解法:拆解 + 规划循环

先说结论:光有「拆解」还不够,拆完还得有人决定「这条查够了没有、要不要继续查」。 所以是两步。

第一步:把问题拆成有序子问题

js 复制代码
const DecomposeSchema = z.object({
  sub_questions: z.array(z.string()).min(1).max(8),
  reason: z.string()
})

const decomposeQuestionNode = async (state) => {
  const decomposer = model.withStructuredOutput(DecomposeSchema);
  const out = await decomposer.invoke(`
    你是《天龙八部》多跳问答的【子问题拆解器】。
    用户原始问题:
    ${state.question}

    任务:将问题拆成**有序**子问题列表 sub_questions, 用于**依次向量检索**。要求:
    1. 链式推理、多层关系、因果先后的问题,必须拆成多条;单跳即可答的也可只输出1条。
    2. 每条子问题必须是**可独立检索**的完整中文问句,**禁止**使用「他/她/此人/上文」等指代;
       可写全人物名与事件名。
    3. 顺序必须符合推理链:先搞清前置实体/事实,再查后续结论。
    4. **不要**把整句原题原样复制成唯一一条(除非确实无法拆分);不要拆成过碎的关键词列表。
    5. 输出 1~8 条即可。
  `);
  // ...
}

这段 prompt 里有三条是踩过坑才会写出来的,值得单独拎出来说:

约束 不写会怎样
禁止使用他/她/此人等指代」 拆出「他的生父是谁?」这种问题,脱离上文根本没法检索------向量库里没有「他」
「顺序必须符合推理链」 顺序错了,第二轮检索时第一轮的答案还不知道,链就断了
「不要拆成过碎的关键词列表」 拆成 ["四大恶人", "叶二娘", "虚竹", "玄慈"]------向量检索用的是语义相似度,一堆关键词的检索效果反而不如一个完整问句

第 2 条是最容易忽略的:拆解器要输出的是「检索用的查询」,不是「给人看的思考步骤」。 查询必须自包含。

第二步:规划节点决定「够不够了」

拆完之后不能闷头把 8 条全查完------可能查到第 3 条就够了,剩下 5 次检索纯属浪费。所以要有个规划节点,每轮重新判断:

js 复制代码
const NextStepSchema = z.object({
  nextAction: z.enum(["retrieve", "generate"]),
  reason: z.string()
})

const planNextStepNode = async (state) => {
  const { nextAction, reason } = await planModel.invoke(`
    你是多跳 RAG 规划器。检索查询已由前置步骤拆解为**有序子问题**,
    若需要继续检索,下一轮将自动使用 [下一条子问题] 做向量检索,你**不要**自拟新的检索句。
    用户原始问题: ${state.question}
    子问题序列:
    ${subList}          // 每条标注「已检索 / 下一轮将检索 / 未检索」

    已检索轮次:${state.retrievalCount}; 剩余未检索子问题条数:${remaining}
    最大检索轮数上限:${state.maxRetrievals}

    已召回文档摘要:
    ${docStr}

    请判断下一步:
    1) 已有足够依据回答用户原始问题 -> nextAction=generate
    2) 仍缺关键事实、且仍存在未检索的子问题、且未超过轮数上限 -> nextAction=retrieve
    硬性规则:
    - 若剩余未检索子问题条数为0, 必须 nextAction=generate。
    - 若已检索轮数已到达或超过最大检索轮数,必须 nextAction=generate。
  `);
  // ...
}

这里最该学的是**「硬性规则」那两句**------它和模型判断是双重保险

js 复制代码
let finalNext = nextAction;
if (state.retrievalCount >= state.maxRetrievals) finalNext = "generate";
if (remaining <= 0) finalNext = "generate";

为什么不能只靠 prompt? 因为 LLM 是会不听话的。你在 prompt 里写了「剩余为 0 时必须 generate」,它照样可能返回 retrieve。而这里如果返回了 retrieve,就轮到 retrieveNode 去取 subs[idx]------下标越界,取到 undefined,然后抛错整个图崩掉

所以作者在代码层面又兜了一道:prompt 负责「应该怎么判断」,代码负责「不许越界」。 循环 + LLM 的组合里,这个模式几乎是标配。

还有个小细节是在 prompt 里明说的:

若需要继续检索,下一轮将自动使用 下一条子问题 做向量检索,你「不要」自拟新的检索句

因为一旦允许规划器自己造查询,它就可能造出和原问题同样模糊的句子,把多跳检索又拉回单跳的老路。让规划器只做「继续 / 停止」的二选一,把「查什么」锁死在拆解阶段------职责单一,行为可控。

循环长这样

js 复制代码
const afterPlan = (state) => state.strategy === "retrieve" ? "retrieve" : "generate"

  .addEdge("decompose_question", "retrieve")
  .addEdge("retrieve", "plan_next_step")
  .addConditionalEdges("plan_next_step", afterPlan, {
    retrieve: "retrieve",      // ← 指回检索,形成循环
    generate: "generate"
  })
%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first route_question(route_question) direct_answer(direct_answer) decompose_question(decompose_question) retrieve(retrieve) plan_next_step(plan_next_step) generate(generate) __end__([<p>__end__</p>]):::last __start__ --> route_question; decompose_question --> retrieve; direct_answer --> __end__; generate --> __end__; retrieve --> plan_next_step; route_question -.-> direct_answer; route_question -.-> decompose_question; plan_next_step -.-> retrieve; plan_next_step -.-> generate; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

那条 plan_next_step -.-> retrieve 指回上游,就是整个 Agentic RAG 的心脏------它让 RAG 从「查一次就完事」变成了「查、看、再决定查不查」。

⚠️ 但这个 demo 里,这条边永远不会被走到

写完上面那张图我去核代码,发现了一个很有代表性的 bug------图是对的,边却永远走不到

规划节点把决策结果写进了 plannedNext

js 复制代码
const planNextStepNode = async (state) => {
  // ...
  console.log(`[决策] plannedNext=${finalNext} (模型建议=${nextAction})(${reason})`)
  return {
    plannedNext: finalNext        // ← 决策写在这里
  }
}

但条件边的路由函数读的是另一个字段

js 复制代码
function afterPlan(state) {
  return state.strategy === "retrieve" ? "retrieve" : "generate"
  //     ^^^^^^^^^^^^^^^ 读的是 strategy
}

strategy 这个字段,全流程只可能被赋三个值""(初始)、"simple""complex"(来自路由节点的 z.enum(["simple","complex"]))。

state.strategy 永远不可能等于 "retrieve" ------ 所以 afterPlan 永远返回 "generate",那条指回 retrieve 的边一次都不会走

实际跑起来,整个多跳流程会退化成:

复制代码
route → decompose → retrieve(只查第一条子问题)→ plan(永远说 generate)→ generate

多跳检索等于没生效。

最坑的地方在于:drawMermaid() 画出来的图和正确版本一模一样 ,那条 plan_next_step -.-> retrieve 清清楚楚地在那儿。可视化能帮你发现「线连错了」,但发现不了「条件永远不成立」------后者只有读路由函数、或者跑起来打日志才看得出来。

所以正确的写法是:

js 复制代码
function afterPlan(state) {
  return state.plannedNext === "retrieve" ? "retrieve" : "generate"
  //     ^^^^^^^^^^^^^^^^^ 读规划节点真正写进去的那个字段
}

教训 :条件边的路由函数和它上游节点的返回值之间,是一个没有类型检查的隐式契约 。上游写 plannedNext、下游读 strategy,两边都不会报错,图也照常编译运行------只是那条边静悄悄地死了。

这是 Agentic RAG 里最容易犯、也最难发现的一类 bug:流程图看起来完整,实际走的是另一条路。

附赠一个工程细节:多轮召回要去重

多轮检索必然召回重复片段。重复不只是浪费:

重复可能让 LLM 产生「我们在强调这一点」的错觉,从而给这段内容过高的权重。

所以每轮结果都要按 id 去重,并且保留分数更高的那条

js 复制代码
const mergeUnique = (existingDocs, newDocs) => {
  const map = new Map();
  for (const d of [...existingDocs, ...newDocs]) {
    const key = String(d.id);
    const prev = map.get(key);
    if (!prev || Number(d.score) > Number(prev.score)) {
      map.set(key, d);      // 同一片段,留分数高的
    }
  }
  return Array.from(map.values()).sort((a, b) => Number(b.score) - Number(a.score));
}

五、毛病三:没有评估和纠错机制

前两版解决的是「要不要查 」和「查几次」。但还有个问题没人管:

查到的内容,到底够不够、准不准?

基线版本对召回结果照单全收------检索质量没有任何校验。向量库没找到相关内容时,它不会说「我不知道」,而是把几段不相关的文字硬塞进 prompt,逼着模型从中憋出一个答案。

解法:加一个评估节点

设计思路是:先查本地,查到的上下文交给 LLM 评估「够不够」;不够就带着缺失点去联网补,补完再评估一次。

js 复制代码
const EvaluateSchema = z.object({
  enough: z.boolean(),                    // 是否足够生成
  missing: z.array(z.string()).max(6),    // 上下文缺的方面
  reason: z.string(),
  web_query: z.string().optional()        // 可选的 web 搜索关键词
})

const evaluateNode = async (state) => {
  const hasWeb = Boolean(state.webContext && String(state.webContext).trim());

  const evaluator = llm.withStructuredOutput(EvaluateSchema);
  const out = await evaluator.invoke(`
    你是信息充分性评估器。判断当前上下文是否足以回答用户问题。
    用户问题: ${state.question}
    已检索上下文(来自本地知识库) :
    ${state.localContext || "(空)"}
    ${hasWeb ? `联网搜索结果:\n ${state.webContext || "(空)"}` : ""}

    输出字段:
    - enough: 是否足够回答(true/false)
    - missing: 若不够,列出缺失信息点(最多6条)
    - reason: 简短原因
    ${hasWeb ? "" : "- web_query: 若不够,给出一个适合互联网搜索的中文查询句(完整句)"}
  `);

  console.log(`${hasWeb ? "二次评估" : "评估"}: enough=${out.enough} (${out.reason})`);
  return { evaluation: JSON.stringify(out) };
}

这个设计里有两点挺巧:

① 同一个节点,跑两次,靠 hasWeb 区分语义。

第一次评估:只有本地上下文 → 越不过就产出 web_query 去联网。 第二次评估:本地 + 联网 → 输出里不再提供 web_query 字段(prompt 里那句三元表达式),断了「无限联网」的路。

用 schema 字段的有无来限制能力 ,比在代码里写 if (round >= 2) 更省事,也更难写错。

missing 是「诊断结果」,不只是个布尔值。

enough: false 只告诉你「不行」,missing: [...] 才告诉你缺什么 ------而这个恰恰就是下一步联网要搜的东西。让评估节点顺手把「缺什么」结构化输出,等于免费得到了下一轮的搜索意图。

图长这样:

%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first route_question(route_question) direct_answer(direct_answer) local_retrieve(local_retrieve) evaluate_local(evaluate_local) __end__([<p>__end__</p>]):::last __start__ --> route_question; direct_answer --> __end__; evaluate_local --> __end__; local_retrieve --> evaluate_local; route_question -.-> direct_answer; route_question -.-> local_retrieve; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

说明一下这段代码的状态 :评估节点本身是完整的,但联网搜索那一段还只到设计为止 ------webContext 已经在 state 里了、web_query 也已经在 schema 里了,但 web_search 节点还没接进图,generate 节点也还没写完(for await 循环还是空的,函数没有返回值)。所以上图最后停在 evaluate_local → END

要把它补完,缺的是这一条路径:

rust 复制代码
evaluate_local --不够且无webContext--> web_search --有webContext--> evaluate_local(二次评估)
                --够了--> generate

这也是这篇的诚实之处:Agentic RAG 的骨架搭起来不难,难的是把每个节点的「出口条件」都堵严实------少堵一个,就是无限循环或者直接崩。


六、毛病四:语义检索匹配不了专业术语

这条比较反直觉,但很真实:

「高血糖」和「低血糖」在自然语义上相似度很高。

对向量检索来说,它俩的 embedding 非常接近。你搜「高血糖」,很可能把「低血糖」的段落召回来------语义相近,但结论完全相反

类似的问题还有一批:专业术语、人名、编号、代码标识符、精确实体。这类内容的检索,关键词匹配(LIKE 查询、正则、全文索引)往往比纯语义检索准得多

所以成熟的 RAG 系统通常是混合检索

检索方式 擅长 短板
向量检索 语义相近、换种说法也能找到 精确术语、反义词容易混淆
关键词检索 精确匹配人名/术语/编号 换个说法就找不到

两家各查一遍,再用 RRF(倒数排名融合)之类的策略合并结果------这在工程上是「加法」,但在效果上是乘法


七、毛病五:本地没有的内容,模型就开始胡说

这个最简单也最致命:

本地知识库没有的内容,LLM 不会说「我不知道」,它会编。

而且它编得很像真的------有细节、有语气、有自信。这正是评估节点存在的另一个理由,也是 prompt 里必须写死的那条:

markdown 复制代码
3. 如果片段中没有相关信息,请如实告知用户

光靠 prompt 是不够的 。真正靠得住的是让「有没有相关信息」变成代码里的判断 ------这就是评估节点在做的事:把「够不够」变成一个 enough: boolean,让程序能据此决定下一步,而不是指望模型自觉。


八、四个版本对照:状态字段的膨胀记录了一切

把四版的 state 声明摆在一起看,会发现一个很有意思的现象------架构的复杂度,全写在状态字段的膨胀里

版本 state 字段 数量
基线 RAG question k documents generation 4
+ 查询路由 上面 4 个 + strategy routeReason 6
+ 多跳检索 上面 6 个 + subQuestions nextSubIdx currentQuery retrievalCount maxRetrievals plannedNext 12
+ 评估纠错 上面若干 + retrievedDocs localContext webContext evaluation 16

多跳那一版多出来的 6 个字段,几乎全是循环控制变量

js 复制代码
subQuestions,    // 拆解出的子问题队列
nextSubIdx,      // 查到第几条了 ------ 循环游标
currentQuery,    // 本轮实际检索的句子
retrievalCount,  // 已查轮次
maxRetrievals,   // 轮数上限 ------ 防止跑飞
plannedNext,     // 上一轮的决策结果

一个朴素的「检索 → 生成」,一旦加进循环,就必须凭空长出一整套循环控制状态。 这是 Agentic RAG 相比普通 RAG 最实质的成本,也是它为什么必须用 LangGraph 这类「图」来编排------线性链没有地方安放「回到上一步」这件事。


九、总结

回到开头那五个毛病,对应的四个解法:

# 毛病 解法 demo
1 所有问题都走检索,浪费且干扰 路由:LLM 先判断 simple / complex rag-query-router.mjs
2 多跳问题一次性检索召不回 拆解 + 规划循环:拆成有序子问题,逐条检索,每轮判断够不够 rag-multihop.mjs
3 没有纠错和评估机制 评估:LLM 判断上下文是否充分,输出缺失点,不够就补 rag-webfallback.mjs
4 专业术语、精确实体检索不准 混合检索:向量 + 关键词,结果融合 ------
5 本地没有的内容模型就胡说 评估节点把「够不够」变成代码可判断的布尔值 同上

把这篇串成一条线:

  1. 固定流程的 RAG 能跑通,但它不会思考------它只会「检索、拼 prompt、生成」,不管该不该检索、检索得够不够、检索得准不准。
  2. 加路由,让它先决定「要不要查」。
  3. 加拆解和规划 ,让它能沿着推理链一条条查,并且每轮重新判断要不要继续------那条指回上游的条件边,就是 Agentic RAG 的心脏
  4. 加评估,让它能说「我不确定」而不是硬编。
  5. 状态字段的膨胀是这一切的代价,也是必须上图编排的原因。
  6. 但图对不等于流程对 :条件边的路由函数读错字段,边就永远走不到,而 drawMermaid 一点异常都看不出来。复杂图的正确性,最终只能靠日志和断点来保证。

最后记一句:

普通 RAG 是「把文档喂给模型」,Agentic RAG 是「让模型自己决定要哪份文档」。

差别不在用了什么向量库,而在那个决定「下一步该干什么」的节点,到底存不存在


本文 demo 都在 agentic_rag/advanced-rag/src 下:ebook-writter.mjs 负责把《天龙八部》epub 切块入库,另外四个是四版检索架构。跑之前需要先建 .envOPENAI_API_KEY / OPENAI_BASE_URL / MODEL_NAME / EMBEDDINGS_MODEL_NAME),并确保 Milvus 里已经有 ebook_collection

如果你也在做 RAG 落地,欢迎评论区交流 👋

相关推荐
梦帮科技1 小时前
从 Guest 到 Platinum:Prompt 配额、API Key 与访问控制的安全闭环
javascript·git·架构·node.js·reactjs·html5·visual studio
linux_cfan1 小时前
videojs v10 源代码系列解读:10 · `DestroyMixin`:双重 rAF 延迟销毁
前端·javascript·音视频
雪芽蓝域zzs1 小时前
第四十六节:顶部【驾驶舱】独立大屏页面实现
前端·javascript·vue.js
右耳朵猫AI2 小时前
Node.js周刊2026W37 | 三处进程崩溃修复、fs 内置 glob、Workers 模块注册表、Vitest 5.0
javascript·后端·node.js
右耳朵猫AI2 小时前
Web前端周刊2026W37 | Shopify 转原生、React 编译 Rust 化、Vitest 5.0、Rslib 1.0
前端·javascript·react.js·typescript·node.js
雪芽蓝域zzs3 小时前
第四十八节:驾驶舱大屏进阶:数字滚动动画 + 渐变边框美化
前端·javascript·vue.js
Bingo_BIG3 小时前
LOV列表的查询
前端·javascript·lov
小静AI工程实验室3 小时前
JS 逆向接口 ID 变了?Python 与 Node.js 复现 JSON 大整数精度丢失
javascript·python·node.js
刘发财12 小时前
前端2秒生成500页矢量PDF,rust真的强到没朋友
前端·javascript·rust