From cb1c6dfcf5eba6745c94d131c01f83f0f1b51a2f Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 02:09:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(multimodal):=20=E5=86=BB=E7=BB=93=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E7=8E=AF=E5=A2=83=E5=B9=B6=E9=9A=94=E7=A6=BB=E8=BF=9F?= =?UTF-8?q?=E5=88=B0=E5=AF=BC=E5=85=A5=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/local_models/runtime.py | 10 ++++++---- backend/tests/test_local_models.py | 4 ++-- backend/tests/test_multimodal_finalization.py | 2 +- backend/tests/test_runtime_components.py | 5 +++-- docs/development/阶段F收尾验收记录.md | 2 +- .../阶段F-Embedding与知识库问题与解决方案.md | 8 ++++++++ .../features/settings/RequestJsonEditor.spec.ts | 17 ++++++++++++++++- .../src/features/settings/RequestJsonEditor.vue | 2 +- 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index e362df0..ed86af0 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -69,9 +69,10 @@ def configure(request): return request -def interpreter(): +def interpreter(config=None): from app.local_models import components - if not os.getenv("APP_MODEL_PYTHON") and configuration().device == "cuda" and components.ready(): + requested_device = (config or configuration()).device + if not os.getenv("APP_MODEL_PYTHON") and requested_device == "cuda" and components.ready(): return components.ROOT / "Scripts/python.exe" return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))) @@ -160,7 +161,8 @@ class Runtime: async def _execute(self, key, operation, payload, config, diagnostics): if read_state(key)["status"] != "installed": raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先下载本地模型。") - if not interpreter().is_file(): + executable = interpreter(config) + if not executable.is_file(): raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。") from app.services.usage_service import UsageAttempt attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local") @@ -170,7 +172,7 @@ class Runtime: env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1", "HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads), "PYTHONIOENCODING": "utf-8"} - args = (str(interpreter()), str(Path(__file__).with_name("worker.py"))) + args = (str(executable), str(Path(__file__).with_name("worker.py"))) options = {"env": env, "limit": 16 * 1024 * 1024, **({"creationflags": 0x08000000} if os.name == "nt" else {})} try: diff --git a/backend/tests/test_local_models.py b/backend/tests/test_local_models.py index 08e9ee7..bbd3a96 100644 --- a/backend/tests/test_local_models.py +++ b/backend/tests/test_local_models.py @@ -47,7 +47,7 @@ def test_local_model_missing_is_explicit(): def test_cancel_reaps_active_model_process(monkeypatch): import app.local_models.runtime as module monkeypatch.setattr(module,'read_state',lambda key:{'status':'installed'}) - monkeypatch.setattr(module,'interpreter',lambda:Path(sys.executable)) + monkeypatch.setattr(module,'interpreter',lambda *_:Path(sys.executable)) class Input: def write(self, value): request = json.loads(value) @@ -92,7 +92,7 @@ def test_subprocess_fallback_runs_and_reaps_real_worker(monkeypatch, tmp_path, c import app.local_models.process as process_module monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'}) - monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable)) + monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable)) worker = tmp_path / 'worker.py' worker.write_text( 'import json,sys,time\n' diff --git a/backend/tests/test_multimodal_finalization.py b/backend/tests/test_multimodal_finalization.py index 002870b..3ddc0b9 100644 --- a/backend/tests/test_multimodal_finalization.py +++ b/backend/tests/test_multimodal_finalization.py @@ -21,7 +21,7 @@ def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code, from app.services.usage_service import connection monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda')) monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'}) - monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable)) + monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable)) events = [] class Process: diff --git a/backend/tests/test_runtime_components.py b/backend/tests/test_runtime_components.py index 6f367e6..b0c7901 100644 --- a/backend/tests/test_runtime_components.py +++ b/backend/tests/test_runtime_components.py @@ -79,7 +79,8 @@ def test_interpreter_keeps_cpu_default_and_respects_explicit_override(monkeypatc (components.ROOT / 'ready.json').write_text('{}') monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cpu')) assert runtime.interpreter() != python - monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cuda')) - assert runtime.interpreter() == python + # A queued attempt keeps its frozen device even after the saved setting changes. + assert runtime.interpreter(runtime.RuntimeConfig(device='cuda')) == python + assert runtime.interpreter(runtime.RuntimeConfig(device='cpu')) != python monkeypatch.setenv('APP_MODEL_PYTHON', 'explicit-python.exe') assert str(runtime.interpreter()) == 'explicit-python.exe' diff --git a/docs/development/阶段F收尾验收记录.md b/docs/development/阶段F收尾验收记录.md index 5adfcb1..53141de 100644 --- a/docs/development/阶段F收尾验收记录.md +++ b/docs/development/阶段F收尾验收记录.md @@ -13,7 +13,7 @@ | 项目 | 结果 | | --- | --- | | 后端全量 `python -m pytest -q -p no:cacheprovider` | 559 通过;1 条已有 Starlette/httpx 弃用提示 | -| 前端全量 `npm test -- --run` | 29 个文件、102 项通过 | +| 前端全量 `npm test -- --run` | 29 个文件、103 项通过 | | 类型与生产构建 `npm run build` | vue-tsc 与 Vite 构建通过,仍有既有大 bundle 提示 | | `git diff --check` | 通过 | | 真实页面 | 模型卡片读取实际大小;音频统计显示真实缺失;提供商表单展示请求编辑、恢复默认、导入/导出及推理验证入口 | diff --git a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md index dd16241..1d74740 100644 --- a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md +++ b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md @@ -138,6 +138,14 @@ embedding_local_only: true ## 9. 工程经验 +### F-18:设备快照与迟到导入错误未完全隔离 + +问题:推理任务虽然冻结了 RuntimeConfig,但启动子进程时又从数据库读取最新 device 来选择 Python 环境;排队期间修改设置会改变已提交任务的运行环境,CUDA → CPU 重试也可能继续使用 CUDA 环境。请求规则的迟到成功响应已失效,但迟到失败仍会把旧错误显示到新草稿。 + +实际方案:`interpreter` 接收本次 attempt 的冻结配置,`_execute` 在检查前解析一次可执行路径并复用;CUDA attempt 使用已验证的独立 CUDA 环境,CPU attempt 使用默认 CPU 环境,显式 APP_MODEL_PYTHON 仍保持最高优先级。导入异常与成功响应使用同一 generation 条件,只允许当前操作更新界面。 + +验证:增加保存设置变化后仍按显式 attempt 选择环境、CPU 重试环境,以及旧导入失败晚于新编辑的回归。最终后端 559 项、前端 103 项和生产构建通过。 + ### F-16:CUDA 选装只有脚本,前端缺少安装入口 问题:上一轮完成了独立 CUDA 环境安装和 GPU 实测,但页面只有设备下拉框及脚本说明。用户无法从前端下载组件,工程收尾遗漏了可操作入口。 diff --git a/frontend/src/features/settings/RequestJsonEditor.spec.ts b/frontend/src/features/settings/RequestJsonEditor.spec.ts index 49d8559..51362dc 100644 --- a/frontend/src/features/settings/RequestJsonEditor.spec.ts +++ b/frontend/src/features/settings/RequestJsonEditor.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { expect, it, vi } from 'vitest' import RequestJsonEditor from './RequestJsonEditor.vue' import { apiClient } from '@/services/apiClient' @@ -38,6 +38,21 @@ it('ignores an imported configuration that finishes after a newer edit', async ( wrapper.unmount() }) +it('ignores an old import failure after a newer edit', async () => { + let fail!: (reason: Error) => void + vi.mocked(apiClient.post).mockReturnValue(new Promise((_resolve, reject) => { fail = reject })) + const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}}) + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', {value:[new File(['{}'], 'old.json')], configurable:true}) + await input.trigger('change') + await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click') + fail(new Error('旧导入失败')) + await flushPromises() + expect(wrapper.text()).not.toContain('旧导入失败') + expect(wrapper.findAll('textarea')).toHaveLength(1) + wrapper.unmount() +}) + it('restores defaults even from an invalid draft and reflects replacement configurations', async () => { const wrapper = mount(RequestJsonEditor, {props:{modelValue:[{capability:'chat', body:{enable_thinking:false}}]}}) await wrapper.get('textarea').setValue('{invalid') diff --git a/frontend/src/features/settings/RequestJsonEditor.vue b/frontend/src/features/settings/RequestJsonEditor.vue index 1040506..a63ca79 100644 --- a/frontend/src/features/settings/RequestJsonEditor.vue +++ b/frontend/src/features/settings/RequestJsonEditor.vue @@ -51,7 +51,7 @@ async function importRules(event: Event) { if (current !== generation) return rules.value = validated.request_overrides.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''})) publish() - } catch(e) { transferError.value = (e as Error).message } + } catch(e) { if (current === generation) transferError.value = (e as Error).message } } async function exportRules() { transferError.value = ''