docs: apply eng review findings to design doc and impl plan

Architecture fixes:
- Image QA: presigned URL → base64 (RustFS is internal, GLM-4V is cloud)
- Add GET /health endpoint + Docker healthcheck
- Video size limit: add get_object_size() to StorageClient ABC, check before background task
- Video size configurable via MAX_VIDEO_SIZE_MB env var (no image rebuild needed)
- Fix image_service.py except clause redundancy (Exception absorbs KeyError/TypeError)

Config additions:
- video.max_file_size_mb: 200 in config.yaml
- MAX_VIDEO_SIZE_MB env override in _ENV_OVERRIDES
This commit is contained in:
wh
2026-04-10 14:34:41 +08:00
parent f9f84937db
commit 3892c6e60f
2 changed files with 145 additions and 31 deletions

View File

@@ -53,6 +53,7 @@ backend: {}
video: video:
frame_sample_count: 8 frame_sample_count: 8
max_file_size_mb: 200
models: models:
default_text: "glm-4-flash" default_text: "glm-4-flash"
@@ -67,6 +68,7 @@ STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin STORAGE_SECRET_KEY=minioadmin
STORAGE_ENDPOINT=http://rustfs:9000 STORAGE_ENDPOINT=http://rustfs:9000
BACKEND_CALLBACK_URL=http://backend:8080/internal/video-job/callback BACKEND_CALLBACK_URL=http://backend:8080/internal/video-job/callback
# MAX_VIDEO_SIZE_MB=200 # 可选,覆盖 config.yaml 中的视频大小上限
``` ```
- [ ] **Step 4: 创建 `requirements.txt`** - [ ] **Step 4: 创建 `requirements.txt`**
@@ -111,6 +113,7 @@ def mock_storage():
client.download_bytes = AsyncMock() client.download_bytes = AsyncMock()
client.upload_bytes = AsyncMock() client.upload_bytes = AsyncMock()
client.get_presigned_url = MagicMock(return_value="https://example.com/presigned/crop.jpg") client.get_presigned_url = MagicMock(return_value="https://example.com/presigned/crop.jpg")
client.get_object_size = AsyncMock(return_value=10 * 1024 * 1024) # 默认 10MB小于限制
return client return client
``` ```
@@ -246,12 +249,13 @@ from dotenv import load_dotenv
_ROOT = Path(__file__).parent.parent.parent _ROOT = Path(__file__).parent.parent.parent
_ENV_OVERRIDES = { _ENV_OVERRIDES = {
"ZHIPUAI_API_KEY": ["zhipuai", "api_key"], "ZHIPUAI_API_KEY": ["zhipuai", "api_key"],
"STORAGE_ACCESS_KEY": ["storage", "access_key"], "STORAGE_ACCESS_KEY": ["storage", "access_key"],
"STORAGE_SECRET_KEY": ["storage", "secret_key"], "STORAGE_SECRET_KEY": ["storage", "secret_key"],
"STORAGE_ENDPOINT": ["storage", "endpoint"], "STORAGE_ENDPOINT": ["storage", "endpoint"],
"BACKEND_CALLBACK_URL": ["backend", "callback_url"], "BACKEND_CALLBACK_URL": ["backend", "callback_url"],
"LOG_LEVEL": ["server", "log_level"], "LOG_LEVEL": ["server", "log_level"],
"MAX_VIDEO_SIZE_MB": ["video", "max_file_size_mb"],
} }
@@ -638,6 +642,15 @@ def test_get_presigned_url(rustfs_client):
Params={"Bucket": "source-data", "Key": "crops/1/0.jpg"}, Params={"Bucket": "source-data", "Key": "crops/1/0.jpg"},
ExpiresIn=3600, ExpiresIn=3600,
) )
def test_get_object_size(rustfs_client):
rustfs_client._mock_s3.head_object.return_value = {"ContentLength": 1024 * 1024 * 50}
size = asyncio.run(rustfs_client.get_object_size("source-data", "video/1.mp4"))
assert size == 1024 * 1024 * 50
rustfs_client._mock_s3.head_object.assert_called_once_with(
Bucket="source-data", Key="video/1.mp4"
)
``` ```
- [ ] **Step 2: 运行,确认失败** - [ ] **Step 2: 运行,确认失败**
@@ -672,6 +685,10 @@ class StorageClient(ABC):
@abstractmethod @abstractmethod
def get_presigned_url(self, bucket: str, path: str, expires: int = 3600) -> str: def get_presigned_url(self, bucket: str, path: str, expires: int = 3600) -> str:
"""生成预签名访问 URL。""" """生成预签名访问 URL。"""
@abstractmethod
async def get_object_size(self, bucket: str, path: str) -> int:
"""返回对象字节大小,用于在下载前进行大小校验。"""
``` ```
- [ ] **Step 4: 实现 `app/clients/storage/rustfs_client.py`** - [ ] **Step 4: 实现 `app/clients/storage/rustfs_client.py`**
@@ -719,6 +736,13 @@ class RustFSClient(StorageClient):
Params={"Bucket": bucket, "Key": path}, Params={"Bucket": bucket, "Key": path},
ExpiresIn=expires, ExpiresIn=expires,
) )
async def get_object_size(self, bucket: str, path: str) -> int:
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None, lambda: self._s3.head_object(Bucket=bucket, Key=path)
)
return resp["ContentLength"]
``` ```
- [ ] **Step 5: 运行,确认通过** - [ ] **Step 5: 运行,确认通过**
@@ -727,7 +751,7 @@ class RustFSClient(StorageClient):
conda run -n label pytest tests/test_storage_client.py -v conda run -n label pytest tests/test_storage_client.py -v
``` ```
Expected: `3 passed` Expected: `4 passed`
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
@@ -816,6 +840,11 @@ app = FastAPI(title="Label AI Service", version="1.0.0", lifespan=lifespan)
app.middleware("http")(request_logging_middleware) app.middleware("http")(request_logging_middleware)
@app.get("/health", tags=["Health"])
async def health():
return {"status": "ok"}
app.add_exception_handler(UnsupportedFileTypeError, unsupported_file_type_handler) app.add_exception_handler(UnsupportedFileTypeError, unsupported_file_type_handler)
app.add_exception_handler(StorageDownloadError, storage_download_handler) app.add_exception_handler(StorageDownloadError, storage_download_handler)
app.add_exception_handler(LLMResponseParseError, llm_parse_handler) app.add_exception_handler(LLMResponseParseError, llm_parse_handler)
@@ -831,11 +860,26 @@ app.add_exception_handler(Exception, generic_error_handler)
# app.include_router(finetune.router, prefix="/api/v1") # app.include_router(finetune.router, prefix="/api/v1")
``` ```
- [ ] **Step 3: Commit** - [ ] **Step 3: 验证 /health 端点**
```bash
conda run -n label python -c "
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
r = client.get('/health')
assert r.status_code == 200 and r.json() == {'status': 'ok'}, r.json()
print('health check OK')
"
```
Expected: `health check OK`
- [ ] **Step 4: Commit**
```bash ```bash
git add app/core/dependencies.py app/main.py git add app/core/dependencies.py app/main.py
git commit -m "feat: DI dependencies and FastAPI app entry with lifespan" git commit -m "feat: DI dependencies, FastAPI app entry with lifespan and /health endpoint"
``` ```
--- ---
@@ -1465,7 +1509,7 @@ async def extract_quadruples(
bbox=bbox, bbox=bbox,
cropped_image_path=crop_path, cropped_image_path=crop_path,
)) ))
except (KeyError, TypeError, Exception) as e: except Exception as e:
logger.warning(f"跳过不完整四元组 index={i}: {e}") logger.warning(f"跳过不完整四元组 index={i}: {e}")
return result return result
@@ -1997,7 +2041,8 @@ def client(mock_llm, mock_storage):
return TestClient(app) return TestClient(app)
def test_extract_frames_returns_202(client): def test_extract_frames_returns_202(client, mock_storage):
mock_storage.get_object_size = AsyncMock(return_value=10 * 1024 * 1024) # 10MB
resp = client.post("/api/v1/video/extract-frames", json={ resp = client.post("/api/v1/video/extract-frames", json={
"file_path": "video/202404/1.mp4", "file_path": "video/202404/1.mp4",
"source_id": 10, "source_id": 10,
@@ -2010,7 +2055,8 @@ def test_extract_frames_returns_202(client):
assert "后台处理中" in resp.json()["message"] assert "后台处理中" in resp.json()["message"]
def test_video_to_text_returns_202(client): def test_video_to_text_returns_202(client, mock_storage):
mock_storage.get_object_size = AsyncMock(return_value=10 * 1024 * 1024) # 10MB
resp = client.post("/api/v1/video/to-text", json={ resp = client.post("/api/v1/video/to-text", json={
"file_path": "video/202404/1.mp4", "file_path": "video/202404/1.mp4",
"source_id": 10, "source_id": 10,
@@ -2020,12 +2066,25 @@ def test_video_to_text_returns_202(client):
}) })
assert resp.status_code == 202 assert resp.status_code == 202
assert resp.json()["job_id"] == 43 assert resp.json()["job_id"] == 43
def test_extract_frames_rejects_oversized_video(client, mock_storage):
mock_storage.get_object_size = AsyncMock(return_value=300 * 1024 * 1024) # 300MB > 200MB limit
resp = client.post("/api/v1/video/extract-frames", json={
"file_path": "video/202404/big.mp4",
"source_id": 10,
"job_id": 99,
"mode": "interval",
"frame_interval": 30,
})
assert resp.status_code == 400
assert "大小" in resp.json()["detail"]
``` ```
- [ ] **Step 2: 实现 `app/routers/video.py`** - [ ] **Step 2: 实现 `app/routers/video.py`**
```python ```python
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from app.clients.llm.base import LLMClient from app.clients.llm.base import LLMClient
from app.clients.storage.base import StorageClient from app.clients.storage.base import StorageClient
@@ -2042,6 +2101,16 @@ from app.services import video_service
router = APIRouter(tags=["Video"]) router = APIRouter(tags=["Video"])
async def _check_video_size(storage: StorageClient, bucket: str, file_path: str, max_mb: int) -> None:
"""在触发后台任务前校验视频文件大小,超限时抛出 HTTP 400。"""
size_bytes = await storage.get_object_size(bucket, file_path)
if size_bytes > max_mb * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"视频文件大小超出限制(最大 {max_mb}MB当前 {size_bytes // 1024 // 1024}MB",
)
@router.post("/video/extract-frames", response_model=ExtractFramesResponse, status_code=202) @router.post("/video/extract-frames", response_model=ExtractFramesResponse, status_code=202)
async def extract_frames( async def extract_frames(
req: ExtractFramesRequest, req: ExtractFramesRequest,
@@ -2049,6 +2118,8 @@ async def extract_frames(
storage: StorageClient = Depends(get_storage_client), storage: StorageClient = Depends(get_storage_client),
): ):
cfg = get_config() cfg = get_config()
bucket = cfg["storage"]["buckets"]["source_data"]
await _check_video_size(storage, bucket, req.file_path, cfg["video"]["max_file_size_mb"])
background_tasks.add_task( background_tasks.add_task(
video_service.extract_frames_background, video_service.extract_frames_background,
file_path=req.file_path, file_path=req.file_path,
@@ -2058,7 +2129,7 @@ async def extract_frames(
frame_interval=req.frame_interval, frame_interval=req.frame_interval,
storage=storage, storage=storage,
callback_url=cfg["backend"]["callback_url"], callback_url=cfg["backend"]["callback_url"],
bucket=cfg["storage"]["buckets"]["source_data"], bucket=bucket,
) )
return ExtractFramesResponse(message="任务已接受,后台处理中", job_id=req.job_id) return ExtractFramesResponse(message="任务已接受,后台处理中", job_id=req.job_id)
@@ -2071,6 +2142,8 @@ async def video_to_text(
storage: StorageClient = Depends(get_storage_client), storage: StorageClient = Depends(get_storage_client),
): ):
cfg = get_config() cfg = get_config()
bucket = cfg["storage"]["buckets"]["source_data"]
await _check_video_size(storage, bucket, req.file_path, cfg["video"]["max_file_size_mb"])
model = req.model or cfg["models"]["default_vision"] model = req.model or cfg["models"]["default_vision"]
prompt = req.prompt_template or video_service.DEFAULT_VIDEO_TO_TEXT_PROMPT prompt = req.prompt_template or video_service.DEFAULT_VIDEO_TO_TEXT_PROMPT
background_tasks.add_task( background_tasks.add_task(
@@ -2086,7 +2159,7 @@ async def video_to_text(
llm=llm, llm=llm,
storage=storage, storage=storage,
callback_url=cfg["backend"]["callback_url"], callback_url=cfg["backend"]["callback_url"],
bucket=cfg["storage"]["buckets"]["source_data"], bucket=bucket,
) )
return VideoToTextResponse(message="任务已接受,后台处理中", job_id=req.job_id) return VideoToTextResponse(message="任务已接受,后台处理中", job_id=req.job_id)
``` ```
@@ -2104,7 +2177,7 @@ app.include_router(video.router, prefix="/api/v1")
conda run -n label pytest tests/test_video_router.py -v conda run -n label pytest tests/test_video_router.py -v
``` ```
Expected: `2 passed` Expected: `3 passed`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
@@ -2225,6 +2298,7 @@ async def test_gen_text_qa_llm_error(mock_llm):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gen_image_qa(mock_llm, mock_storage): async def test_gen_image_qa(mock_llm, mock_storage):
mock_llm.chat_vision.return_value = '[{"question":"图中是什么?","answer":"电缆接头"}]' mock_llm.chat_vision.return_value = '[{"question":"图中是什么?","answer":"电缆接头"}]'
mock_storage.download_bytes.return_value = b"fake-image-bytes"
items = [ImageQuadrupleForQA( items = [ImageQuadrupleForQA(
subject="电缆接头", predicate="位于", object="配电箱", qualifier="", cropped_image_path="crops/1/0.jpg" subject="电缆接头", predicate="位于", object="配电箱", qualifier="", cropped_image_path="crops/1/0.jpg"
)] )]
@@ -2232,7 +2306,12 @@ async def test_gen_image_qa(mock_llm, mock_storage):
result = await gen_image_qa(items=items, model="glm-4v-flash", prompt_template="", llm=mock_llm, storage=mock_storage) result = await gen_image_qa(items=items, model="glm-4v-flash", prompt_template="", llm=mock_llm, storage=mock_storage)
assert len(result) == 1 assert len(result) == 1
assert result[0].image_path == "crops/1/0.jpg" assert result[0].image_path == "crops/1/0.jpg"
mock_storage.get_presigned_url.assert_called_once_with("source-data", "crops/1/0.jpg") # 验证使用 download_bytesbase64而非 presigned URL
mock_storage.download_bytes.assert_called_once_with("source-data", "crops/1/0.jpg")
# 验证发送给 GLM-4V 的消息包含 base64 data URL
call_messages = mock_llm.chat_vision.call_args[0][0]
image_content = call_messages[1]["content"][0]
assert image_content["image_url"]["url"].startswith("data:image/jpeg;base64,")
``` ```
- [ ] **Step 3: 运行,确认失败** - [ ] **Step 3: 运行,确认失败**
@@ -2246,12 +2325,13 @@ Expected: `ImportError`
- [ ] **Step 4: 实现 `app/services/qa_service.py`** - [ ] **Step 4: 实现 `app/services/qa_service.py`**
```python ```python
import base64
import json import json
import logging import logging
from app.clients.llm.base import LLMClient from app.clients.llm.base import LLMClient
from app.clients.storage.base import StorageClient from app.clients.storage.base import StorageClient
from app.core.exceptions import LLMCallError, LLMResponseParseError from app.core.exceptions import LLMCallError, LLMResponseParseError, StorageDownloadError
from app.core.json_utils import parse_json_response from app.core.json_utils import parse_json_response
from app.models.qa_models import ( from app.models.qa_models import (
ImageQAPair, ImageQAPair,
@@ -2326,7 +2406,12 @@ async def gen_image_qa(
result = [] result = []
prompt = prompt_template or DEFAULT_IMAGE_QA_PROMPT prompt = prompt_template or DEFAULT_IMAGE_QA_PROMPT
for item in items: for item in items:
presigned_url = storage.get_presigned_url(bucket, item.cropped_image_path) # 下载裁剪图并 base64 编码RustFS 为内网部署presigned URL 无法被云端 GLM-4V 访问
try:
image_bytes = await storage.download_bytes(bucket, item.cropped_image_path)
except Exception as e:
raise StorageDownloadError(f"下载裁剪图失败 {item.cropped_image_path}: {e}") from e
b64 = base64.b64encode(image_bytes).decode()
quad_text = json.dumps( quad_text = json.dumps(
{k: v for k, v in item.model_dump().items() if k != "cropped_image_path"}, {k: v for k, v in item.model_dump().items() if k != "cropped_image_path"},
ensure_ascii=False, ensure_ascii=False,
@@ -2334,7 +2419,7 @@ async def gen_image_qa(
messages = [ messages = [
{"role": "system", "content": "你是专业的视觉问答对生成助手。"}, {"role": "system", "content": "你是专业的视觉问答对生成助手。"},
{"role": "user", "content": [ {"role": "user", "content": [
{"type": "image_url", "image_url": {"url": presigned_url}}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
{"type": "text", "text": prompt + quad_text}, {"type": "text", "text": prompt + quad_text},
]}, ]},
] ]
@@ -2813,6 +2898,12 @@ services:
networks: networks:
- label-net - label-net
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
rustfs: rustfs:
image: minio/minio:latest image: minio/minio:latest

View File

@@ -125,6 +125,7 @@ backend: {} # callback_url 由 .env 注入
video: video:
frame_sample_count: 8 # 视频转文本时均匀抽取的代表帧数 frame_sample_count: 8 # 视频转文本时均匀抽取的代表帧数
max_file_size_mb: 200 # 视频文件大小上限(超过则拒绝,防止 OOM
models: models:
default_text: "glm-4-flash" default_text: "glm-4-flash"
@@ -139,6 +140,7 @@ STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin STORAGE_SECRET_KEY=minioadmin
STORAGE_ENDPOINT=http://rustfs:9000 STORAGE_ENDPOINT=http://rustfs:9000
BACKEND_CALLBACK_URL=http://backend:8080/internal/video-job/callback BACKEND_CALLBACK_URL=http://backend:8080/internal/video-job/callback
# MAX_VIDEO_SIZE_MB=200 # 可选,覆盖 config.yaml 中的视频大小上限
``` ```
### 3.4 config 模块实现 ### 3.4 config 模块实现
@@ -154,12 +156,13 @@ _ROOT = Path(__file__).parent.parent.parent
# 环境变量 → YAML 路径映射 # 环境变量 → YAML 路径映射
_ENV_OVERRIDES = { _ENV_OVERRIDES = {
"ZHIPUAI_API_KEY": ["zhipuai", "api_key"], "ZHIPUAI_API_KEY": ["zhipuai", "api_key"],
"STORAGE_ACCESS_KEY": ["storage", "access_key"], "STORAGE_ACCESS_KEY": ["storage", "access_key"],
"STORAGE_SECRET_KEY": ["storage", "secret_key"], "STORAGE_SECRET_KEY": ["storage", "secret_key"],
"STORAGE_ENDPOINT": ["storage", "endpoint"], "STORAGE_ENDPOINT": ["storage", "endpoint"],
"BACKEND_CALLBACK_URL": ["backend", "callback_url"], "BACKEND_CALLBACK_URL": ["backend", "callback_url"],
"LOG_LEVEL": ["server", "log_level"], "LOG_LEVEL": ["server", "log_level"],
"MAX_VIDEO_SIZE_MB": ["video", "max_file_size_mb"],
} }
def _set_nested(d: dict, keys: list[str], value: str): def _set_nested(d: dict, keys: list[str], value: str):
@@ -351,6 +354,17 @@ app = FastAPI(title="Label AI Service", lifespan=lifespan)
统一前缀:`/api/v1`。FastAPI 自动生成 Swagger 文档(`/docs`)。 统一前缀:`/api/v1`。FastAPI 自动生成 Swagger 文档(`/docs`)。
### 5.0 健康检查
**`GET /health`**
```json
// 响应200 OK
{"status": "ok"}
```
用于 Docker healthcheck、Nginx 上游探测、运维监控。无需认证,不访问外部依赖。
### 5.1 文本三元组提取 ### 5.1 文本三元组提取
**`POST /api/v1/text/extract`** **`POST /api/v1/text/extract`**
@@ -541,7 +555,7 @@ POST {BACKEND_CALLBACK_URL}
} }
``` ```
图像 QA 生成时AI 服务通过 `get_presigned_url` 获取裁剪图临时访问 URL构造多模态消息后调用 GLM-4V。 图像 QA 生成时AI 服务通过 `storage.download_bytes` 重新下载裁剪图base64 编码后直接嵌入多模态消息,避免 RustFS 内网 presigned URL 无法被云端 GLM-4V 访问的问题
### 5.7 提交微调任务 ### 5.7 提交微调任务
@@ -628,6 +642,8 @@ def extract_text(data: bytes, filename: str) -> str:
**抽帧BackgroundTask** **抽帧BackgroundTask**
``` ```
0. storage.get_object_size(bucket, file_path) → 字节数
超过 video.max_file_size_mb 限制 → 回调 FAILED路由层提前校验返回 400
1. storage.download_bytes → bytes → 写入 tempfile 1. storage.download_bytes → bytes → 写入 tempfile
2. cv2.VideoCapture 打开临时文件 2. cv2.VideoCapture 打开临时文件
3. interval 模式:按 frame_interval 步进读帧 3. interval 模式:按 frame_interval 步进读帧
@@ -659,9 +675,10 @@ def extract_text(data: bytes, filename: str) -> str:
图像 QA 图像 QA
遍历四元组列表 遍历四元组列表
storage.get_presigned_url(cropped_image_path) → 临时 URL storage.download_bytes(bucket, cropped_image_path) → bytes → base64 编码
构造多模态消息(image_url + 问题指令) 构造多模态消息(data:image/jpeg;base64,... + 问题指令)
llm.chat_vision → 解析 → 含 image_path 的 QAPairList llm.chat_vision → 解析 → 含 image_path 的 QAPairList
(注:不使用 presigned URL因 RustFS 为内网部署,云端 GLM-4V 无法访问内网地址)
``` ```
### 6.5 finetune_service — GLM 微调对接 ### 6.5 finetune_service — GLM 微调对接
@@ -764,6 +781,12 @@ ai-service:
- backend - backend
networks: networks:
- label-net - label-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
``` ```
### 10.3 requirements.txt ### 10.3 requirements.txt
@@ -799,9 +822,9 @@ ZhipuAI 官方 SDK 是同步阻塞调用,直接 `await` 不生效。通过 `lo
项目规模适中,视频处理任务由 ADMIN 手动触发并发量可控。FastAPI `BackgroundTasks` 无需额外中间件Redis 队列、Celery Worker部署简单任务状态通过回调接口传递给 Java 后端管理,符合整体架构风格。 项目规模适中,视频处理任务由 ADMIN 手动触发并发量可控。FastAPI `BackgroundTasks` 无需额外中间件Redis 队列、Celery Worker部署简单任务状态通过回调接口传递给 Java 后端管理,符合整体架构风格。
### 11.4 为何图像 QA 生成用 presigned URL 而非 base64 ### 11.4 为何图像 QA 生成用 base64 而非 presigned URL
裁剪图已存储在 RustFSGLM-4V 支持通过 URL 直接访问图片。presigned URL 避免将图片内容重新加载到 AI 服务内存后再 base64 编码,减少内存压力,适合多张图片批量生成的场景 RustFS 部署在 Docker 内网(`http://rustfs:9000`presigned URL 指向内网地址,云端 GLM-4V API 无法访问,会导致所有图像 QA 请求失败。因此将裁剪图重新下载为 bytesbase64 编码后直接嵌入多模态消息体,与 `image_service` 处理原图的方式保持一致,无需 RustFS 有公网地址
### 11.5 config.yaml + .env 分层配置的原因 ### 11.5 config.yaml + .env 分层配置的原因