乐于分享
好东西不私藏

AI Agent-流水线的眼睛:NodeMDImg 让图片可被理解、可被检索,逐行讲透

AI Agent-流水线的眼睛:NodeMDImg 让图片可被理解、可被检索,逐行讲透

前两篇把骨架和入口讲完了。这一篇来到流水线的「眼睛」——NodeMDImg。它是 PDF 路径和 MD 路径的汇流点,也是流水线里唯一一个和多模态大模型打交道的节点——把本地图片变成「VLM 生成的描述 + MinIO 公网 URL」,下游才能稳定引用、可被检索。

492 行代码藏着五段工程品味:正则从 .*? 演化到字符类的血泪、按章节边界给 VLM 上下文而不是按字数硬切、滑动窗口限流器、re.sub 替换字符串的反向引用坑。逐行讲完你会看到:「让流水线会看图」这件事远比想象中精巧。


一、定位:汇流点 + 协议终结点

先把流水线再摆一遍,注意看 node_md_img 在哪儿:

node_entry (入口分流)
   ├── file_type=md  ─► node_md_img ─► node_document_split ─► ...
   └── file_type=pdf ─► node_pdf_to_md ─► node_md_img ─► node_document_split ─► ...
                                                                  │
                                                                  ▼
                                                         node_item_name_recognition
                                                         node_bge_embedding
                                                         node_import_milvus
                                                         node_knowledge_graph ─► END

两个细节决定这个节点的角色:

  • 它是两条路径的汇流点——PDF 走完 node_pdf_to_md 之后流到这里,MD 直接从 node_entry 流到这里。汇流之后所有节点拿到的都是同一形态的 MD
  • 它是协议终结点——汇流之前,MD 里的图片还是 ![](images/xxx.jpg) 这种本地相对路径,离开这台机器就失效;汇流之后变成 ![VLM 生成的摘要](https://minio.../xxx.jpg),公网可访问、描述自带。下游节点完全不用再关心图片。

一句话总结它干的活:扫描 MD 里被引用的本地图片 → 调 VLM 给每张图写描述 → 上传 MinIO → 用新引用替换旧引用。听起来四步,代码里被拆成了 process 编排 + 六个私有方法。

process 的骨架:6 步编排 + 增量返回

先看 process 全貌,调度逻辑一眼就能看完:

def process(self, state: ImportGraphState) -> dict:
    # 步骤1:初始化数据,获取MD核心信息
    md_content, md_path_obj, images_dir = self._step_1_get_content(state)
    if not images_dir.exists():
        self.logger.info("无图片文件夹,跳过图片处理")
        return {"md_content": md_content}

    # 步骤2:扫描并筛选MD中引用的图片
    target_images = self._step_2_scan_images(md_content, images_dir)
    if not target_images:
        self.logger.info("未检测到MD中引用了图片,跳过图片处理")
        return {"md_content": md_content}

    # 步骤3:调用多模态大模型生成图片摘要
    summaries = self._step_3_generate_summaries(md_path_obj.stem, target_images)

    # 步骤4:上传图片至MinIO,替换MD图片路径并填充摘要
    new_md_content = self._step_4_upload_and_replace(md_path_obj.stem, target_images, summaries, md_content)

    # 步骤5:备份并保存新MD文件
    new_md_file_path = self._step_5_backup_new_md_file(md_path_obj, new_md_content)

    # 步骤6:只返回增量字段,由 LangGraph merge 进 state(与 node_pdf_to_md 一致)
    return {
        "md_content": new_md_content,
        "md_path": new_md_file_path
    }

三个工程取舍先点出来:

  • 两道早退闸门
    ——没有 images 文件夹(纯文本 MD),或者 images 里有图但 MD 没引用(MinerU 输出的图片和正文对不上),都直接 return 跳过,不浪费一次 VLM 调用。这是「先过滤再做事」的早退风格,和上一节 NodePDFToMD 的 _step_1_validate_paths 是一个套路。
  • 六个 _step_N_xxx 私有方法
    ——和 NodePDFToMD 一样的拆分粒度:process 只负责编排,每步一个私有方法,单元测试可以瞄准单个 step 写。
  • 返回增量 dict 不改入参
    ——结尾注释专门写「与 node_pdf_to_md 一致」。同一个流水线里两个重节点用同一种状态修改风格,NodeEntry 那种直接改 state 的反而成了异类(上一篇提过的债)。

记忆点节点的「单一职责」不是只做一件事,而是只暴露一个稳定的入参契约 + 一个稳定的增量返回契约。内部拆多少 step 是私事,外部永远只看到一个 process(state) -> dict


二、双源策略:state 优先,回退读盘

第一节说过这是汇流点——两条上游路径产生的 state 形态不一样:

  • PDF 路径:node_pdf_to_md 把转换后的 MD 内容写进 state["md_content"],同时把新文件路径写进 state["md_path"]
  • MD 直入路径:node_entry 只写了 state["md_path"]没有 md_content——还没读过盘。

_step_1_get_content 用一个回退链把这两条路收口:

# 1. 参数非空校验
md_path = state.get("md_path")
if not md_path:
    raise StateFieldError(field_name="md_path", message="MD文件路径不能为空", expected_type=str)

# 2. 路径转换与有效性检查
md_path_obj = Path(md_path)
if not md_path_obj.is_file():
    raise FileProcessingError(message=f"文件 {md_path_obj.name} 不存在或不是文件")

# 3. 获取MD内容:PDF 路径上游已放进 state 直接复用;
# MD 直入路径(entry 只写 md_path)state 里没有内容,需回退读盘
md_content = state.get("md_content")
if not md_content:
    with open(md_path_obj, "r", encoding="utf-8") as f:
        md_content = f.read()

# 4. 获取md文件的图片文件夹路径
img_dir = md_path_obj.parent / "images"

return md_content, md_path_obj, img_dir

这段写得很克制,但背后的设计值得点出来:state 是缓存层,磁盘是真相层md_content 在 state 里就直接用上游算出来的结果(省一次读盘),不在就回退到磁盘(保证功能完整)。这和上一节 NodePDFToMD 下载 ZIP 后用 Path.rename 把 MD 落盘是配套的——上游一定先落盘再写 state,下游有 state 用 state、没 state 读盘,两边都能跑。

第 4 步那个 md_path_obj.parent / "images" 是个隐式契约:图片必须放在 MD 同级目录的 images/ 子目录里。MinerU 输出的就是这个布局,所以 PDF 路径没问题;但用户手动准备 MD 时如果图片放在 assets/ 或者直接和 MD 平级,这个节点就找不到图——这是隐式契约的代价。

⚠️ 隐式契约的代价images/ 这个目录名硬编码在代码里,既不在 config.py 也不能从 MD 内容推断。生产上没问题(MinerU 输出固定),开放给用户上传 MD 时就是坑。修法是把目录名提成配置,或者扫描 MD 里的 ![](xxx) 反推出真实图片目录。


三、扫描图片:用集合查成员、用 debug 不制造噪音

把 images/ 目录扫一遍,挑出「格式支持 + MD 真的引用了」的图片:

for image_path_obj in images_dir.iterdir():
    if not image_path_obj.is_file():
        continue
    image_file = image_path_obj.name

    # 1. 过滤无效后缀
    # images 目录里出现非图片文件属正常情况(如解析附带的 json),用 debug 而非 warning
    if image_path_obj.suffix.lower() not in self.config.image_extensions:
        self.logger.debug(f"图片{image_file}格式不支持")
        continue

    # 2. 组装图片的完整路径并转成字符串
    img_path = str(image_path_obj)

    # 3. 查找这个图片在md文档中引用的上下文
    context = self._find_image_in_md(md_content, image_file)
    if not context:
        self.logger.debug(f"图片 {image_file} 未在md文档中找到")
        continue

    # 4. 将查询到的图片组装到列表中
    target_images.append((image_file, img_path, context))

三个工程取舍:

  • iterdir() 替代 os.listdir()
    ——注释明确写了「与项目其他节点风格统一」。pathlib 迭代器返回的是 Path 对象,后续 .is_file() / .suffix / .name 都不用再 os.path.join 拼。
  • 非图片文件用 debug 不用 warning
    ——MinerU 输出目录里夹杂 jsontxt 是正常情况不是异常。日志级别的本质是「这条信息值得不值得运维看」,warning 刷屏会让真问题被淹没。
  • 三元组 (file, path, context)
    ——把上下文和文件信息一起带着走,下一步 VLM 直接用。如果只存 (file, path),到 VLM 那一步还得重新算一次上下文,O(n) 重复扫描。把数据「在最便宜的地方算好一起带走」是流水线优化的通用套路。

正则系统:一个共用方法,三个细节,一年的坑

这是这一篇最值得慢慢看的一段代码。先看 _build_img_ref_pattern 的完整实现——只有一行,但注释比代码长得多:

@staticmethod
def _build_img_ref_pattern(image_file: str) -> re.Pattern:
    """
        构建 MD 图片引用正则,匹配形如 ![](images/xxx.jpg)。
        筛选(_find_image_in_md)和替换(_process_md_file)共用同一构建方法,保证两处永远一致。
        描述用 [^\\]]*、路径用 [^)]*:懒惰的 .*? 遇到不匹配时仍会扩张跨过相邻引用,
        把两个引用连成一个匹配;文件名前必须是 ( 或 /,避免 1.jpg 误匹配 a1.jpg
    """
    return re.compile(r"!\[[^\]]*\]\((?:[^)]*/)?" + re.escape(image_file) + r"\)")

三个细节,一个一个拆。这是真正写过生产代码被坑过的人才能写出的正则

细节一:为什么不用 .*?

90% 的工程师第一反应会写:

r"!\[.*?\]\(.*?" + re.escape(image_file) + r"\)"

看起来很合理——懒惰匹配嘛。但拿这串去匹配一段 MinerU 输出的 MD:

![](images/fig1.jpg)
正文
![](images/fig2.jpg)

当你想找 fig1.jpg 时,.*? 在「找不到 fig1.jpg」时会一直扩张——直到把两个引用合并成一个匹配,从第一个 ![ 一直吃到第二个 )]。替换之后整段 MD 就乱了。

懒惰匹配不是「匹配最短的」,而是「在能让整个正则成功的所有方案里选最短的」。当正则在当前位置找不到目标时,它会回溯——继续扩张。这是 .*? 的反直觉之处。

解法就是用字符类排除[^\]]*(描述部分不含 ])、[^)]*(路径部分不含 ))。这种「就地封顶」的写法把每个引用锁在自己的括号里,跨不过去。

⚠️ 别迷信 .*?:懒惰量词只在「能匹配到」时取最短;匹配不到时它会一直扩张,跨过相邻同结构片段。遇到「同结构的相邻重复」时优先用字符类排除,把每段匹配锁在自己的边界里。

细节二:文件名前的边界感

正则里这一段 (?:[^)]*/)? 是处理 images/xxx.jpg 这种带目录前缀的写法。但还有个更细的坑:找 1.jpg 时,a1.jpg / b1.jpg 都会被匹配——因为 1.jpg 是它们的后缀。

解法是「文件名前必须是 ( 或 /」:( 表示引用刚开头(![](1.jpg)),/ 表示在某个目录下(![](images/1.jpg))。a1.jpg 前面是字母 a,被边界卡掉。

这是正则里的「左边界」技巧——和 SQL 注入防御、shell 引号转义是同一种思维方式:不光要匹配到,还要保证匹配的位置是「真实开头」

细节三:re.escape 是默认动作

文件名拼进正则前先过 re.escape(image_file)——这是纯防御。MinerU 输出的图片文件名通常是 fig_1.jpg 这种安全字符,但万一某天用户上传的 PDF 里有 图 (1).jpg图+1.jpg 这种带正则元字符的文件名,不 escape 就直接语法错或乱匹配。

这是「外部输入进正则必须 escape」的硬规则——和「外部输入进 SQL 必须参数化」、「外部输入进 HTML 必须转义」是同一类防御。re.escape 的成本是零,漏了就是 bug。

共用方法的价值:注释明确写了「筛选_find_image_in_md)和替换_process_md_file)共用同一构建方法,保证两处永远一致」。这一句注释是整个正则系统的灵魂——筛选到的图被替换的图必须是同一批,否则要么替换不到,要么替换错。共用方法把这种「两处必须一致」的约束编码进了代码结构,以后改正则只改一个地方。


四、上下文章节感知:给 VLM 一个语义完整的语境

这是这一篇里最有品味的一段代码。先看朴素做法会怎么写:

# ❌ 朴素做法:图片前后各取 200 字
idx = md_content.find(image_ref)
pre_text = md_content[max(0, idx-200):idx]
post_text = md_content[idx+len(image_ref):idx+len(image_ref)+200]

能跑,但有两个问题:

  • 章节跨越
    ——如果图片正好在「第三章」末尾,post_text 会把「第四章」的内容也算成上下文。VLM 看到的语境是错乱的。
  • 噪音混入
    ——前后 200 字会把相邻图片的 ![](xxx) 引用也吃进去。VLM 看到「上文:![](fig_1.jpg)」对生成当前图片的摘要毫无帮助。

看看这个节点是怎么做的:

# 结构感知的上下文提取用到的两个行级正则
_HEADING_RE = re.compile(r"^#{1,6}\s+")            # MD 标题行
_IMG_LINE_RE = re.compile(r"^!\[[^\]]*\]\([^)]*\)$")  # 独立成行的图片引用

def _find_image_in_md(self, md_content, image_file, context_len=200):
    """
        按行定位图片引用,返回结构化上下文(最近上级标题, 上文, 下文)。
        上下文以章节为边界(不跨标题),并剔除相邻图片引用行
    """
    pattern = self._build_img_ref_pattern(image_file)
    lines = md_content.split("\n")

    for idx, line in enumerate(lines):
        if not pattern.search(line):
            continue

        # 1. 向上找最近的标题行,上文只取该标题之后的正文
        head_title = ""
        pre_start = 0
        for i in range(idx - 1, -1, -1):
            if self._HEADING_RE.match(lines[i]):
                head_title = lines[i]
                pre_start = i + 1
                break

        # 2. 向下截到下一个标题为止(不含标题本身)
        post_end = len(lines)
        for i in range(idx + 1, post_end):
            if self._HEADING_RE.match(lines[i]):
                post_end = i
                break

        # 3. 上文取靠近图片的末尾片段,下文取靠近图片的开头片段
        pre_text = self._collect_context(lines[pre_start:idx], context_len, near_end=True)
        post_text = self._collect_context(lines[idx + 1:post_end], context_len, near_end=False)
        return head_title, pre_text, post_text

    return None

三步,每一步都解决一个具体问题:

  1. 向上找最近的标题
    ——确定章节起点pre_start = i + 1,上文从标题下一行开始(标题本身单独存进 head_title,不重复进正文)。
  2. 向下截到下一个标题
    ——确定章节终点post_end = i(不含标题行),保证下文不会越界进下一章。
  3. 切片方向不对称
    ——上文取末尾 context_len 字(near_end=True),下文取开头 context_len 字(near_end=False)。两边都向图片靠拢——离图片越近的内容相关性越强。

再看 _collect_context 怎么处理噪音:

@classmethod
def _collect_context(cls, lines, max_chars, near_end):
    """
        拼接正文行为上下文:剔除空行和独立图片行(相邻图片的引用标记对摘要是噪音)
    """
    text = "\n".join(
        line for line in lines
        if line.strip() and not cls._IMG_LINE_RE.match(line.strip())
    )
    return text[-max_chars:] if near_end else text[:max_chars]

两个过滤条件:

  • line.strip()
     过滤空行——空行对摘要零贡献。
  • not cls._IMG_LINE_RE.match(line.strip())
     过滤独立成行的图片引用——相邻图片的标记是噪音,吃进去会让 VLM 困惑。

注意 _IMG_LINE_RE 用了 ^...$ 锚定整行——只过滤「整行就是一张图片引用」的情况,行内夹带的图片(比如 这是一张示意图 ![](fig.jpg) 看这里)不会被剔除,因为这种行有正文信息。

上下文工程的核心:给模型语义完整的语境,而不是字数对齐的窗口。按字数硬切看起来上下文长度一致,但语义上可能跨越章节、夹带噪音;按章节边界切看起来长度不一,但每段都是完整的语义单元。VLM 拿到「第三章 3.2 节 安装步骤 ...... 图片 ...... 注意事项 ......」比拿到「字数 200 字 200 字」能生成准确得多的描述。

这是这一篇里我最想强调的工程品味。Prompt 工程的精华不在 prompt 模板,而在「喂给模型什么数据」。同样的 prompt 模板,喂「图片前后 200 字」和喂「图片所在章节的纯净正文」,效果天差地别。

记忆点:上下文工程 = 语义边界(按章节不按字数)+ 噪音剔除(空行、相邻图片)+ 方向不对称(向目标靠拢)。三步都为了一个目标——让每 token 都携带相关信号


五、限流:滑动窗口 deque,参数提成类常量

调 VLM 是花钱的,更要命的是——各家多模态 API 都有严格的速率限制,超了就 429。这一节看节点怎么做限流。先看 _step_3_generate_summaries 的骨架:

def _step_3_generate_summaries(self, doc_stem, target_images):
    summaries = {}

    # 模型配置全程不变,客户端建一次全批复用;原先每张图 new 一个 ChatOpenAI,纯浪费
    chat_model = ChatOpenAI(
        model=llm_config.vl_model,
        api_key=llm_config.api_key,
        base_url=llm_config.base_url,
        temperature=llm_config.llm_temperature
    )

    # 双端队列
    request_deque = deque()
    for img_file, img_path, context in target_images:
        # 3.1 限速
        self._apply_api_rate_limit(request_deque, self.RATE_LIMIT_MAX_REQUESTS, self.RATE_LIMIT_WINDOW_SECONDS)
        # 3.2 向模型发送请求
        summaries[img_file] = self._summarize_image(chat_model, img_path, root_folder=doc_stem, image_content=context)

    return summaries

两个工程取舍:

  • 客户端建一次全批复用
    ——注释专门写了「原先每张图 new 一个 ChatOpenAI,纯浪费」。ChatOpenAI 内部维护 HTTP 连接池(httpx.Client),每张图重建一次意味着 TLS 握手、TCP 慢启动全重来。10 张图就是 10 倍延迟。
  • deque 外部初始化跨调用复用
    ——request_deque = deque() 在循环外创建,所有图片共享一个时间戳队列,限流才是「全批 N 次/窗口」而不是「每张图独立 N 次/窗口」。

类常量化的故事:注释藏着一次重构

看类顶部这两行——和注释一起读:

# 限流参数提成类常量:原先硬编码在调用处,既是魔法数字,又和 docstring 里
# 写的「每分钟9次」对不上;集中定义后按大模型实际限额只需改这一处
RATE_LIMIT_MAX_REQUESTS = 3
RATE_LIMIT_WINDOW_SECONDS = 10

这段注释是重构纪事:原版把 max_requests=5, window=60 写死在调用处,docstring 里却写「每分钟 9 次」——代码和文档各自漂移。提成类常量是止血的第一步,但更彻底的修法是把这两个参数从 llm_config 读——不同模型的限额不同,切模型时只改配置不动代码。

注意数值本身:3 次 / 10 秒 等效于 18 次 / 分钟,比原先的 5 次 / 分钟 宽松得多。这是一个实测后调整的数字,不是拍脑袋的。

算法拆解:滑动窗口三步走

把 _apply_api_rate_limit 完整贴出来——核心算法很短:

def _apply_api_rate_limit(self, request_times, max_requests, window_seconds=60):
    # 1. 记录当前时间
    current_time = time.time()

    # 2. 清理滑动窗口中的过期请求
    while request_times and current_time - request_times[0] >= window_seconds:
        request_times.popleft()

    # 3. 窗口内请求数达到上限,计算需要等待的时间并阻塞
    if len(request_times) >= max_requests:
        sleep_duration = window_seconds - (current_time - request_times[0])
        if sleep_duration > 0:
            self.logger.info(f"请求被限速,等待{sleep_duration:.2f}秒...")
            time.sleep(sleep_duration)
            current_time = time.time()
            while request_times and current_time - request_times[0] >= window_seconds:
                request_times.popleft()

    # 4. 记录当前请求的时间戳,新请求入队
    request_times.append(current_time)

三步:

  1. 清过期
    ——从队头开始,把超过窗口时长的时间戳统统 popleft()deque 是按入队顺序排列的,队头一定是最老的,从队头清一定对。
  2. 满了就睡
    ——如果剩下的还是 ≥ max_requests,就算还要等多久:window_seconds - (current_time - request_times[0])。算的是「队头那个请求离过期还有多久」,time.sleep 阻塞等。睡醒之后还要再清一次过期——睡的过程中时间过去了,可能有更多请求过期了。
  3. 入队
    ——把自己的时间戳追加到队尾。下次调用会看到它。

这就是经典的滑动窗口限流(sliding window log)。和「固定窗口」相比它的优势是不会出现边界突刺——固定窗口在窗口切换的瞬间可以让请求翻倍(0:59 发 N 个 + 1:00 发 N 个 = 1 秒内 2N 个),滑动窗口随时回看过去 N 秒,这种突刺不会发生。

但代价是空间 O(N)——要存所有时间戳。对图片批处理这个量级(几十张)完全无所谓,限流到几万 QPS 时就该换令牌桶(token bucket)或滑动窗口平均数(sliding window counter)算法了。

⚠️ 老问题又来了time.time() 测时间间隔——和上一篇 NodePDFToMD 轮询循环是同一个坑。time.time() 是系统时钟,会被 NTP 校时、夏令时、人工改时间影响——如果系统时钟往回拨,current_time - request_times[0] 可能变成负数,限流逻辑直接乱套。测时间间隔必须用 time.monotonic()——它保证单调不减,不受系统时钟影响。这是 Python 标准库里专门为「测间隔」准备的函数。

记忆点:滑动窗口限流三件套——时间戳队列(按时间排序)+ 队头清过期(O(1) 摊销)+ 满了 sleep(阻塞而非拒绝)。空间 O(N) 换精确度,单机小流量首选;分布式大流量换 Redis + 令牌桶。


六、VLM 调用:base64 编码、MIME 真实后缀、文件名兜底

_summarize_image 是真正「看图说话」的地方,几个细节都值得点:

image_path_obj = Path(image_path)
# 1. 将图片转换成base64, 发给 llm
with open(image_path, "rb") as f:
    base64_image = base64.b64encode(f.read()).decode("utf-8")

# 按真实后缀生成 MIME:原先硬编码 image/jpeg,png/webp 会被错误标注
mime_type = mimetypes.types_map.get(image_path_obj.suffix.lower(), "image/jpeg")

# 拼装结构化上下文,跳过为空的部分
head_title, pre_text, post_text = image_content
context_text = "\n".join(part for part in (head_title, pre_text, post_text) if part) or "暂无可用上下文"

try:
    messages = [
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    text": f"""这是"{root_folder}"文件中的一张图片,其所在章节及上下文如下:
"{context_text}"
请结合图片视觉内容和上述上下文,用中文简要总结这张图片的内容,用于 Markdown 图片标题。"""
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:{mime_type};base64,{base64_image}"
                    }
                }
            ]
        }
    ]
    response = chat_model.invoke(messages)
    return response.content.strip().replace("\n", "")

except Exception as e:
    self.logger.error(f"获取图片摘要失败:{image_path}, 错误:{e}")
    # 兜底用图片文件名而不是文件夹名:文件夹名和图片内容毫无关联,
    # 落到 MD 里所有失败图共用同一个标题,读者无法分辨;文件名至少可辨认
    return image_path_obj.stem

六个细节:

  • base64 内嵌而不是 URL
    ——这是 OpenAI 多模态 API 的两种图片输入方式。本地图片用 data:image/xxx;base64,... 直接塞进 request body;如果图片已经在公网(比如 OSS URL)就传 URL 让模型自己下载。这里图片还在本地,base64 是唯一选择。
  • MIME 按真实后缀生成
    ——注释又藏了一笔重构:原先硬编码 image/jpeg,PNG、WebP 会被错标成 JPEG。OpenAI API 对 MIME 错配的容错性其实挺好,但有些模型会严格校验,错标会直接 400。按真实后缀查 mimetypes.types_map,查不到才回落到 image/jpeg,这是稳妥的兜底。
  • 顶部 mimetypes.add_type("image/webp", ".webp")
    ——Windows 自带的 MIME 表不全,.webp 查不到会回落 image/jpeg。模块加载时手动注册一次,进程内永久生效。这是「平台差异主动抹平」的标准做法。
  • 上下文空值跳过
    ——"\n".join(part for part in (head_title, pre_text, post_text) if part),生成器 + if part 真值过滤,or "暂无可用上下文" 兜底全空。三段都不存在时也不会让 prompt 出现空字符串。
  • 返回值 .strip().replace("\n", "")
    ——VLM 经常会返回带换行的多句描述,但 Markdown 图片标题里换行会破坏引用结构。replace("\n", "") 强制压成一行——简单粗暴但有效。
  • 异常兜底用文件名不用文件夹名
    ——这一段注释特别有教育意义:「文件夹名和图片内容毫无关联,落到 MD 里所有失败图共用同一个标题,读者无法分辨」。失败的图全标成「万用表RS-12的使用」,读者完全分不清是哪张;标成 fig_01 / fig_02 至少能定位。兜底值的可辨识性 > 兜底值的相关性

异常兜底三原则:① 兜底值必须可辨识(这值是从哪个失败来的);② 兜底值必须可定位(出了问题能溯源);③ 兜底之后流程继续跑而不是整批失败。return image_path_obj.stem 这一行三个原则全占——可辨识(文件名唯一)、可定位(路径在错误日志里)、不阻断(流程继续走 MinIO 上传)。

但这里有个细节值得挑:except Exception as e 是宽口径异常——网络超时、模型 5xx、API key 错、JSON 解析失败,全兜进来。优点是抗造(一张图失败不影响整批),缺点是掩盖真正的环境问题。如果 API key 配错了,你会看到一堆「fig_01 / fig_02 / fig_03」全是兜底值,不点开日志根本不知道是 key 错了。生产做法是分级兜底httpx.TimeoutException / 5xx 这种可重试的异常重试 N 次再兜底;401 / 403 这种不可重试的异常直接 raise 让上层感知。


七、MinIO 上传 + MD 替换:清旧不阻断、协议跟随、lambda 防反向引用

_step_4_upload_and_replace 是一个六步编排里的小编排:

# 1. 获取MinIO客户端
minio_client = get_minio_client()

# 2. 获取图片上传路径
minio_dir = minio_config.img_dir
upload_dir = f"{minio_dir}/{doc_stem}".replace(" ", "")

# 3. 清理已有目录
self._clean_minio_dir(minio_client, upload_dir)

# 4. 批量上传
urls = self._upload_images_batch(minio_client, upload_dir, target_images)

# 5. 合并图片摘要和URL,过滤上传失败的图片
image_info = self._merge_summary_and_url(summaries, urls)

# 6. 替换MD中的图片引用
md_content = self._process_md_file(md_content, image_info)

return md_content

六个 step 各管一件事,但里面藏着三个工程取舍值得逐个拆。

取舍一:_clean_minio_dir 失败不阻断

def _clean_minio_dir(self, minio_client, upload_dir):
    try:
        objects_to_delete = minio_client.list_objects(minio_config.bucket_name, upload_dir, recursive=True)
        delete_list = [DeleteObject(obj.object_name) for obj in objects_to_delete]
        # 目录为空(首次处理该文档)时直接返回,不发无意义的批量删除请求
        if not delete_list:
            return
        errors = minio_client.remove_objects(minio_config.bucket_name, delete_list)
        for error in errors:
            self.logger.error(f"删除图片错误:{error}")
    except Exception as e:
        # 清理失败只影响旧图残留,不阻断后续上传,故记日志后继续
        self.logger.error(f"清理MinIO目录 {upload_dir} 失败:{e}")

这段的核心是「清理」和「上传」是降级关系——清理失败没关系,上传会覆盖旧图;上传失败才是真问题。except 块的注释明确写了「不阻断后续上传」,这是对故障模式的清醒分类

两个细节:

  • 目录为空直接 return
    ——首次处理该文档时 MinIO 上没有旧数据,remove_objects 传一个空列表是浪费一次 RPC。if not delete_list: return 是早退风格。
  • remove_objects 返回错误迭代器
    ——MinIO SDK 的设计是批量删除容忍部分失败,返回的是一个 errors 迭代器而不是直接抛异常。不迭代 errors 就看不到错误——这是个常见的 SDK 陷阱。

取舍二:_upload_to_minio 协议跟随配置

try:
    content_type = mimetypes.guess_type(local_path)[0] or "application/octet-stream"
    minio_client.fput_object(
        bucket_name=minio_config.bucket_name,
        object_name=object_name,
        file_path=local_path,
        content_type=content_type,
    )
    # 协议跟随 secure 配置,硬编码 http 会在开启 https 后生成全部失效的链接
    scheme = "https" if minio_config.secure else "http"
    return f"{scheme}://{minio_config.endpoint}/{minio_config.bucket_name}/{object_name}"
except Exception as e:
    self.logger.error(f"上传图片失败:{local_path},错误:{e}")
    return None

两个看点:

  • scheme = "https" if minio_config.secure else "http"
    ——注释又是一次重构纪事:原先硬编码 http://,开 HTTPS 后生成的 URL 全部失效(HTTP 资源嵌入 HTTPS 页面会被浏览器 block)。「协议跟随配置」是基础工程素养,但容易被忘。
  • return None 作为失败哨兵
    ——上传失败不抛异常,返回 None,下游 _merge_summary_and_url 用 if url := urls.get(image_file) 过滤掉。用 None 做哨兵值的争议:优点是简单,缺点是调用方很容易忘记检查——某天有人新写了个消费 urls 的代码忘了 None check 就会炸。更稳的做法是返回 Optional<str> 并在类型层强约束(mypy 配合)。

取舍三:_process_md_file 用 lambda 防反向引用

这是这一篇的最后一个高光点。看 _process_md_file

def _process_md_file(self, md_content, image_info):
    for image_file, (summary, new_url) in image_info.items():
        # 与筛选阶段共用 _build_img_ref_pattern,保证「筛选到的图」和「替换到的图」一致
        pattern = self._build_img_ref_pattern(image_file)
        # 用 lambda 而非替换字符串:摘要里若含 \ 会被 re.sub 当作反向引用而报错
        md_content = pattern.sub(lambda m: f"![{summary}]({new_url})", md_content)
    return md_content

为什么不直接写 pattern.sub(f"![{summary}]({new_url})", md_content)

因为 re.sub(repl_string, ...) 的第二个参数如果传字符串,会把字符串里的反斜杠当作反向引用——\1 引用第一个分组、\g<name> 引用命名分组、\\ 是字面反斜杠。

VLM 生成的摘要里出现 \ 完全可能——比如「C:\Users\ screenshot」、「路径分隔符 \」、「图 A\B 对照」。这时 re.sub 会把 \U 当成 Unicode 转义、\1 当成反向引用——要么报错,要么替换出乱七八糟的字符串。

解法是传函数不传字符串pattern.sub(lambda m: replacement, text)。函数的返回值直接当作字面字符串使用,不做任何转义解释。这个细节是 Python re 模块的「必须知道」冷知识,但不知道的人不在少数——很多人写了多年 Python 也没踩到,是因为他们的替换字符串刚好不含反斜杠。外部数据进 re.sub 必须用函数形式,这是个硬规则。

⚠️ re.sub 替换字符串的反向引用陷阱re.sub(pattern, repl_string, text) 会在 repl_string 里解释 \N / \g<name> / \\。替换内容来自外部数据(用户输入、模型生成)时,必须用 pattern.sub(lambda m: ..., text) 让返回值按字面处理。

最后两笔:备份原文件 + 增量返回

def _step_5_backup_new_md_file(self, md_path_obj, md_content):
    new_md_path_obj = md_path_obj.with_name(f"{md_path_obj.stem}_new.md")
    with open(new_md_path_obj, "w", encoding="utf-8") as f:
        f.write(md_content)
    return str(new_md_path_obj)

原文件不变,新内容写 _new.md——这是数据安全的兜底。万一替换逻辑出了 bug,或者 VLM 摘要全错,原文件还在,可以重新跑。这是「写不动原文件」的工程纪律——和数据库迁移永远备份原表是同一种谨慎。

然后 process 末尾返回 {"md_content": new_md_content, "md_path": new_md_file_path}——增量 dict,LangGraph 自动 merge 进 state。下游 node_document_split 拿到的是带 MinIO URL 的 MD,从此再也不用关心图片在哪


八、业界对照:能换什么、该换什么、别换什么

盘点这个节点可以怎么进化——分「生产可换」「规模到了再换」「别换」三类。

限流:滑动窗口 → 令牌桶 + 异步并发

当前是串行 + sleep:一张图调完睡一会儿再下一张。10 张图、每张 2 秒 + 限流等待,整个节点可能跑 30 秒以上。生产可改:

import asyncio
from aiolimiter import AsyncLimiter

# 18 次 / 分钟 = 0.3 次 / 秒,平滑发放
limiter = AsyncLimiter(max_rate=18, time_period=60)

async def summarize_all(images):
    async def one(img):
        async with limiter:
            return await async_vlm_call(img)
    return await asyncio.gather(*[one(img) for img in images])

三个升级点:

  • 异步并发
    ——asyncio.gather 让多张图的 HTTP 等待并行,单张 2 秒不变但整体 10 张从 20 秒压到 2-3 秒。
  • 令牌桶代替滑动窗口
    ——aiolimiter.AsyncLimiter 内部就是令牌桶,空间 O(1),平滑发放令牌,单机百万 QPS 都 hold 得住。
  • SDK 切异步版
    ——langchain_openai.ChatOpenAI 已经支持 ainvoke,不用换库。

但前提是整条流水线都异步化——单独把这一个节点改异步,外层 asyncio.run 包一下也能跑,但效果打折扣。LangGraph 本身支持 async 节点,全局升级是更彻底的路。

正则:手写正则 → Markdown AST

当前用正则匹配图片引用——能跑,但扛不住所有 Markdown 方言。比如带 title 的引用 ![alt](url "title")、HTML 标签 <img src="...">、引用式图片 ![alt][ref],正则都漏。

彻底的方案是用 Markdown 解析器——mistune / markdown-it-py——把 MD 解析成 AST,遍历 image 节点替换 src 再 render 回字符串。优点是覆盖所有方言,缺点是多一层依赖 + 性能开销。MinerU 输出的 MD 格式相对固定,正则目前够用——但代码里至少要加一行注释说「仅支持 ![](url) 标准语法」,否则后人接手时不知道边界。

VLM 调用:可观测性升级

当前可观测性靠 logging.info/error,能跑但排查问题全靠 grep 日志。生产推荐三件套:

  • structlog
    ——结构化日志,每条日志带 task_id / img_file / elapsed_ms,ELK / Loki 直接查询。
  • OpenTelemetry tracing
    ——每张图一个 span,链路追踪看哪张图慢、哪张图失败、整体耗时分布。
  • metrics(Prometheus)
    ——VLM 调用成功率、p50/p99 延迟、限流触发次数,Grafana 大盘实时看。

这一块不是这一篇的重点,但必须提——「能跑」和「能运维」之间差的就是可观测性


九、收尾:492 行里的五条工程纪律

这一篇走完了。回头看,NodeMDImg 的 492 行藏着五条可以背下来的工程纪律:

  1. 正则遇到「同结构相邻重复」用字符类排除,不用 .*?
    ——懒惰量词匹配不到时会回溯扩张,[^\]]* / [^)]* 把匹配锁在边界里。
  2. 外部数据进正则必须 escape,进 re.sub 必须用 lambda
    ——re.escape 防文件名元字符,lambda 防替换字符串里的反斜杠被反向引用。
  3. 上下文工程的精华在「喂什么」不在「怎么问」
    ——按章节边界切 + 剔除噪音 + 向目标靠拢,比按字数硬切有效得多。
  4. 限流器算法选型看规模
    ——单机小流量用滑动窗口(精确、简单),分布式大流量用令牌桶(空间 O(1)、平滑)。
  5. 异常兜底要可辨识、可定位、不阻断
    ——文件名兜底比文件夹名兜底好,因为它唯一;但宽口径 except Exception 在生产里要分级。

必改清单(按投入产出比排序)

  1. time.time() → time.monotonic()
    ——限流器和上一篇的轮询循环是同一个坑,全局替换 5 分钟搞定。
  2. 限流参数从 llm_config 读
    ——切模型时改一处配置,不动代码。
  3. _upload_to_minio 返回 Optional[str] + mypy 强约束
    ——防「忘了 check None」。
  4. VLM 异常分级
    ——可重试(超时 / 5xx)用 tenacity 重试,不可重试(401 / 403)直接 raise。
  5. 正则边界写进 docstring
    ——「仅支持 ![](url) 标准语法」,后人接手时知道边界在哪。

可选改清单(规模到了再做)

  • 限流器换 aiolimiter.AsyncLimiter + 全节点 async 化——单 PDF 图片数到 50+ 时收益明显。
  • 正则换 markdown-it-py——支持用户上传自定义 MD 时考虑。
  • images/
     目录名提成配置或从 MD 反推——开放用户上传 MD 时考虑。
  • 加 OpenTelemetry + structlog——多实例部署、需要链路追踪时考虑。

这一篇的核心 takeaway正则不是写出来的是改出来的——三个细节 (.*?→字符类、左边界、re.escape) 每一个都是真实坑的痕迹。上下文工程的精华在「喂什么」不在「怎么问」——按章节边界给 VLM 语境,比按字数硬切有效得多。外部数据进 re.sub 必须用 lambda——这是一条值得刻进肌肉记忆的规则。


「项目骨架精读」系列,每一处结论都基于真实仓库源码逐行核实。这一篇把 NodeMDImg 492 行里的正则演化、章节感知上下文、限流算法、VLM 兜底、MinIO 上传、re.sub 反向引用坑全拆了一遍。要是帮你少踩一个坑,点个赞、点个在看,转给写流水线的朋友。关注我,下一篇把切分节点 node_document_split 的递归切分、重叠窗口、token 计数拆透。

基本 文件 流程 错误 SQL 调试
  1. 请求信息 : 2026-08-12 05:00:01 HTTP/1.1 GET : https://www.yeyulingfeng.com/a/925734.html
  2. 运行时间 : 0.396139s [ 吞吐率:2.52req/s ] 内存消耗:4,885.63kb 文件加载:145
  3. 缓存信息 : 0 reads,0 writes
  4. 会话信息 : SESSION_ID=2e0295d9a833cdbfaaefa2b627d7eaed
  1. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/public/index.php ( 0.79 KB )
  2. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/autoload.php ( 0.17 KB )
  3. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/autoload_real.php ( 2.49 KB )
  4. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/platform_check.php ( 0.90 KB )
  5. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/ClassLoader.php ( 14.03 KB )
  6. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/autoload_static.php ( 6.05 KB )
  7. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
  8. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
  9. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/ralouphie/getallheaders/src/getallheaders.php ( 1.60 KB )
  10. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
  11. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
  12. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
  13. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
  14. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
  15. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
  16. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
  17. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
  18. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
  19. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
  20. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/guzzlehttp/guzzle/src/functions_include.php ( 0.16 KB )
  21. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/guzzlehttp/guzzle/src/functions.php ( 5.54 KB )
  22. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
  23. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
  24. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
  25. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/provider.php ( 0.19 KB )
  26. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
  27. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
  28. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
  29. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/common.php ( 0.03 KB )
  30. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/helper.php ( 18.78 KB )
  31. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
  32. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/alipay.php ( 3.59 KB )
  33. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
  34. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/app.php ( 0.95 KB )
  35. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/cache.php ( 0.78 KB )
  36. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/console.php ( 0.23 KB )
  37. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/cookie.php ( 0.56 KB )
  38. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/database.php ( 2.48 KB )
  39. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/filesystem.php ( 0.61 KB )
  40. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/lang.php ( 0.91 KB )
  41. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/log.php ( 1.35 KB )
  42. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/middleware.php ( 0.19 KB )
  43. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/route.php ( 1.89 KB )
  44. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/session.php ( 0.57 KB )
  45. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/trace.php ( 0.34 KB )
  46. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/view.php ( 0.82 KB )
  47. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/event.php ( 0.25 KB )
  48. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
  49. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/service.php ( 0.13 KB )
  50. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/AppService.php ( 0.26 KB )
  51. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
  52. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
  53. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
  54. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
  55. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
  56. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/services.php ( 0.14 KB )
  57. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
  58. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
  59. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
  60. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
  61. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
  62. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
  63. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
  64. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
  65. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
  66. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
  67. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
  68. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
  69. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
  70. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
  71. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
  72. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
  73. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
  74. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
  75. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
  76. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
  77. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
  78. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
  79. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
  80. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
  81. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
  82. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
  83. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
  84. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
  85. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
  86. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
  87. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/Request.php ( 0.09 KB )
  88. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
  89. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/middleware.php ( 0.25 KB )
  90. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
  91. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
  92. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
  93. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
  94. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
  95. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
  96. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
  97. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
  98. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
  99. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
  100. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
  101. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
  102. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
  103. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/route/app.php ( 4.22 KB )
  104. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
  105. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
  106. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
  107. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/controller/Index.php ( 9.87 KB )
  108. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/BaseController.php ( 2.05 KB )
  109. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
  110. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
  111. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
  112. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
  113. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
  114. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
  115. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
  116. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
  117. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
  118. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
  119. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
  120. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
  121. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
  122. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
  123. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
  124. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
  125. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
  126. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
  127. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
  128. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
  129. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
  130. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
  131. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
  132. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
  133. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
  134. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
  135. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/controller/Es.php ( 3.11 KB )
  136. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
  137. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
  138. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
  139. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
  140. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
  141. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
  142. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
  143. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
  144. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/runtime/temp/c935550e3e8a3a4c27dd94e439343fdf.php ( 31.50 KB )
  145. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
  1. CONNECT:[ UseTime:0.000868s ] mysql:host=127.0.0.1;port=3306;dbname=wenku;charset=utf8mb4
  2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.001776s ]
  3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000924s ]
  4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.001956s ]
  5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.001583s ]
  6. SELECT * FROM `set` [ RunTime:0.003661s ]
  7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.001791s ]
  8. SELECT * FROM `article` WHERE `id` = 925734 LIMIT 1 [ RunTime:0.015002s ]
  9. UPDATE `article` SET `lasttime` = 1786482001 WHERE `id` = 925734 [ RunTime:0.030692s ]
  10. SELECT * FROM `fenlei` WHERE `id` = 64 LIMIT 1 [ RunTime:0.011055s ]
  11. SELECT * FROM `article` WHERE `id` < 925734 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.006930s ]
  12. SELECT * FROM `article` WHERE `id` > 925734 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.001015s ]
  13. SELECT * FROM `article` WHERE `id` < 925734 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.072142s ]
  14. SELECT * FROM `article` WHERE `id` < 925734 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.030780s ]
  15. SELECT * FROM `article` WHERE `id` < 925734 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.016710s ]
0.399383s