Skip to content

feat(knowledge): 图谱抽取的单次调用超时可配置 - #1038

Open
zgpnuaa wants to merge 2 commits into
xerrors:mainfrom
zgpnuaa:feat/graph-extraction-timeout
Open

zgpnuaa wants to merge 2 commits into
xerrors:mainfrom
zgpnuaa:feat/graph-extraction-timeout

Conversation

@zgpnuaa

@zgpnuaa zgpnuaa commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

变更说明

问题:LLM 图谱抽取器对每个分块调用一次模型,而这次调用的超时写死 60 秒extractors/llm.pyselect_model(..., timeout=60.0, ...))。

在一次真实的图谱构建(914 个分块)里实测:

抽取模型 单块抽取耗时 结果
推理型模型(配置里指定) 中位 ~40s、P90 ~69s 约 1/5 的抽取第一次必然超时
轻量档模型 中位 39.6s、P90 69.4s、325 个样本中 61 次 >55s 依然频繁踩线

超时后走最多 3 次重试,所以最终 抽取失败 计数是 0——表面上没坏,代价是约 1/5 的抽取白跑 2–4 次,浪费 LLM 调用与时间。构建耗时从数分钟被拉到十几分钟。

而且这个超时没法从 UI 改yuxi/models/chat.py_langchain_kwargs

langchain_kwargs = dict(kwargs.pop("model_params", {}) or {})
langchain_kwargs.update(kwargs)   # kwargs 覆盖 model_params

调用方显式传了 timeout=60.0,所以用户在「模型参数 JSON」里写 {"timeout": 300} 一直是静默无效的。这一点在 UI 上也看不出来。

改动:把超时变成 extractor_options.timeout_seconds,并在图谱抽取配置弹窗里加一个输入框(含提示)。默认保持 60 秒不变,不改变既有部署的行为。

  • 任务类型:feature
  • 目标:让单次抽取超时可按模型与分块规模调整。
  • 非目标:不改 _langchain_kwargs 的覆盖顺序(见「替代方案」);不引入"单块总预算"语义(当前 timeout 是单次 HTTP 请求的预算,见「未验证范围与风险」)。
  • 改动面:3 文件,+164/-5。

决策记录:按维护者在 #1031 中的意见(「可以将 Decision 文件……移除后合并」)本 PR 未附 decision record。取舍与风险写在本描述与代码注释里。如需补一份,我可以加。

工程主张与 Owner

  • 主张 1extractor_options.timeout_seconds 生效于抽取调用的模型客户端。
    Owner:LLMGraphExtractor.extract() 传入 select_modeltimeout
  • 主张 2:不配置时行为与改动前一致(60 秒)。
    Owner:DEFAULT_EXTRACTION_TIMEOUT_SECONDS_resolve_timeout_seconds 的默认分支。
  • 主张 3:非法配置在保存时就以 400 拒绝,不允许出现「保存成功、构建时整库失败」。
    Owner:_resolve_timeout_seconds 的校验 + 路由把 ValueError 映射为 400。

验证情况

主张 1:配置值真的传到模型调用

  • 直接证据 / 命令:docker run ... pytest test/unit/graphs -q53 项通过。其中 test_llm_graph_extractor_passes_configured_timeout_to_model 断言 select_model 收到 timeout=300.0
  • 负向案例:把 extract() 改回硬编码 timeout=60.0 后该用例失败(变异校验已跑)。
  • 结果:Passed

主张 2:默认值不变

  • 直接证据:test_llm_graph_extractor_defaults_timeout_when_absentextract() 断言默认 60.0 也真的传下去。
  • 结果:Passed

主张 3:非法配置在保存时被拒绝

  • 失败面(这三类都是"保存成功但构建全挂"):{"timeout_seconds": true}float(True)==1.0 被区间校验放行,配出 1 秒超时;NaN/inf → 区间比较恒为 False 被放行;超大整数 → float()OverflowError,穿透成 500 而非 400。
  • 直接证据 / 命令:参数化用例覆盖 0 / -1 / 600.0001 / "abc" / null / "" / true / false / nan / "NaN" / inf / 10**400,全部断言抛 ValueError;另有正向用例断言 300 / "300" / 600 / 0.5 被接受(补上界与字符串数字,避免 off-by-one 漏测)。
  • 负向案例:去掉 isinstance(raw, bool)math.isfinite(...) 两个守卫后,[true][nan] 两条用例以正确原因失败(变异校验已跑)。
  • 结果:Passed

主张 4:真实页面可配置且往返一致

  • 直接证据:在运行中的实例走 UI——弹窗出现「单次抽取超时(秒)」字段(默认 60)→ 改成 180 → 保存提示「图谱抽取配置已更新」→ 重新打开弹窗显示 180;回读数据库:extractor_options = {"schema": "", "model_spec": "...", "model_params": {}, "timeout_seconds": 180, "concurrency_count": 50}
  • 结果:Passed

主张 5:不回归

  • 直接证据 / 命令:ruff check → All checks passed;前端 eslint . --max-warnings=0 通过、node --test 351 项通过vite build 退出码 0;git diff --check 干净。
  • 结果:Passed

简化 / 删除验收

不涉及(feature)。

独立语义 Review

已执行。由不具备开发上下文的全新 Reviewer 覆盖需求、完整 diff、测试与规范。其发现并已修复的问题:

  1. timeout_seconds: true 被静默接受为 1.0 秒(float(True)==1.0 落在合法区间内)→ 保存返回 200,构建时每块必超时、全部标记失败。已修(显式拒绝 bool)+ 用例。
  2. NaN / "NaN" / inf 被静默接受(区间比较对 NaN 恒为 False)→ 配置阶段通过、抽取阶段直接报错。已修math.isfinite)+ 用例。
  3. 超大整数触发未捕获的 OverflowError → HTTP 500 而非 400。已修exceptOverflowError)+ 用例。
  4. 错误文案「必须在 0 到 600 秒之间」与实现矛盾(0 实际被拒)。已修为「必须大于 0 且不超过 600 秒」。
  5. 前端提示「建议 180–300」与默认值 60 自相矛盾。已修为「默认 60 秒。……若日志出现反复超时重试,可调大至 180–300。」

Reviewer 同时确认了本 PR 的两条核心论据成立(_langchain_kwargs 的覆盖顺序、默认值与旧硬编码等价),并明确排除了若干怀疑(None 语义、两次解析会不一致、字符串数字、与 concurrency_count 的对称性)。

替代方案(为什么不那么做)

  • 只改默认值:省事,但既没解决「不同模型需要不同值」,也把风险强加给所有部署。不足以替代。
  • _langchain_kwargsmodel_params 能覆盖 timeout否决。那会让用户可控的任意 JSON 覆盖所有调用点的显式 kwargs——包括 metadata(yuxi 在这里盖 provider/model 身份戳)、会话路由用的 default_headersstream_usagemax_retries,甚至 api_key/base_url(会直接引发重复关键字参数冲突)。爆炸半径远大于一个专用字段,且 model_params 目前没有任何 schema 校验。本 PR 采用「专用字段 + 显式传入」是更收敛的做法。
  • 真要做「单块总预算」:应在 model.call 外面包 asyncio.wait_for。当前 timeout单次 HTTP 请求的预算,语义见下节。属后续项,不在本 PR。

未验证范围与风险

  • 默认不变 = 存量部署不改配置就没有收益Passed,取舍已明确):本 PR 让问题「可修」而非「已修」。是否应当直接调大默认值(120–180 比 600 更稳妥)属仓库策略,我没有替维护者决定。
  • timeout_seconds 不是单块总预算Passed,语义澄清):它是 per-HTTP-request 的超时,而 ChatOpenAI 默认 max_retries=2(全仓未设置),所以单块最坏耗时可达 配置值 × 3。实测 timeout=1.0 时单次尝试打了 3 个 HTTP 请求、耗时 4.3s。这既解释了背景里「最大到过 234s」,也意味着把上限设成 600 的静默成本比直观更大。
  • 存量 model_params.timeout 仍静默无效Not run):新输入框的 placeholder 提示了新用户,但已经按老办法在 model_params 里写 timeout 的部署不会得到任何提示。是否需要对存量配置做一次性告警,请维护者判断。
  • 未做真实图谱构建验证Not run):本 PR 只改了超时来源与配置校验,未在真实构建中对比「60s vs 调大后的重试次数」。背景数据来自改动前的生产观测。
  • 前端边界常量与后端各有一份Passed,可接受):Vue 侧 60/600 与后端常量重复,将来若调整会静默漂移;更严的做法是由配置接口下发 min/max/default。

界面变更

「修改图谱抽取配置」弹窗新增「单次抽取超时(秒)」,位于「LLM 抽取并发数」右侧;「模型参数 JSON」移到下一行整行显示(并在 placeholder 里点明它不能用于设置超时)。

超时配置项

(截图为配置弹窗本身,不含账号或知识库内容;托管在 fork 的独立分支 docs/pr-screenshots,未混入本 PR 的 diff。)

关联事项

无关联 Issue。

补充说明

  • 兼容性:新增的 timeout_secondsextractor_options 里的可选键,缺省即旧行为;不改变任何既有配置的解析结果,也不需要数据迁移。已在库的配置(如 {"model_spec": ..., "concurrency_count": 50})会继续以 60 秒运行,用户想调整时在 UI 里改即可。
  • 校验风格:与既有的 concurrency_count 保持一致(try/except + 区间 + 中文错误),并额外挡住了 bool / 非有限值 / 溢出这三类会被静默放行的输入。
  • 数据迁移:无。

背景:LLM 图谱抽取器对每个分块调一次模型,该调用的超时写死 60 秒。生产实测配置的
抽取模型是推理型模型,单块抽取耗时中位约 40s、P90 约 69s,于是约 1/5 的抽取第一次
必然超时,靠最多 3 次重试才勉强成功——抽取失败计数为 0,但白烧了 LLM 调用与时间。
(注意 timeout 是单次 HTTP 请求的预算,openai SDK 内部还有 max_retries,因此单块
实际耗时可能远大于配置值。)

同时该超时无法通过现有的「模型参数 JSON」设置:yuxi/models/chat.py 的
_langchain_kwargs 里是 langchain_kwargs.update(kwargs),显式传入的 timeout 会覆盖
model_params 里的同名项,所以写在模型参数里的 timeout 一直是静默无效的。

改动:新增 extractor_options.timeout_seconds,并在图谱抽取配置弹窗里加输入框与提示。
默认保持 60 秒不变,避免改变既有部署的行为。

校验上挡掉了三类会被静默放行、进而在构建期整库失败的输入:bool(float(True)==1.0
会配出 1 秒超时)、NaN/inf(使区间比较恒为 False)、超大整数(float() 抛 OverflowError,
会穿透成 500 而非 400)。
CI 的 Ruff Format & Lint 在这一行失败:line-length 设为 120,该 raise 收成一行即可容纳,
ruff format 要求合并。本地 ruff 能完整复现同一报错(非版本差异),已按格式化结果修改。
纯格式,无行为变化。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant