# 容器部署与报告接入 当前主通风机批次使用[固定工作流](MAIN_FAN_WORKFLOW.md):Python/PDFium 文本提取、GLM OCR 扫描页识别、Qwen3.5 单次 JSON 研判。服务器本地测试保持两个推送开关均为 False。 镜像含 Python 3.12、锁定的依赖、前端、预缓存的分词词表及 Linux bubblewrap。单进程运行;数据库、workspace、输出和日志分别通过卷持久化。`.env`、运行数据及 PDF 不进入镜像。修改配置后重建容器生效。 ## 准备配置 复制 `.env.example` 为 `.env`,填写模型、网关、`BUSINESS_API_TOKEN` 和需要的结果平台配置。保留既有真实配置时不覆盖它。`SHANXI_GATEWAY_ENABLED=True` 后配置真实网关 URL/token,才能启动批次服务。 本部署设置 `BILLING_ENABLED=False`:不登记模型单价、不计算费用,保留每次请求的输入/输出 Token、模型及执行记录。无需配置 `BILLING_ALLOW_UNPRICED`;Token 用量持久化和可选 Token 限额仍保留。 默认思考级别为 `DEEPAGENT_THINKING=highest`。Qwen3.5 使用 vLLM 的 `chat_template_kwargs.enable_thinking`:`off` 显式传 `false`,`high/highest` 显式传 `true`;该模型接口没有独立的最高深度档位,不发送 `reasoning_effort=highest`。模型识别兼容 `Qwen/Qwen3.5-35B-A3B-FP8` 等带组织前缀、量化后缀的名称,并保留实际请求模型名。`GET /api/model` 的 `thinking_parameters` 和启动日志显示请求使用的思考开关;它们不代替模型服务响应的核验。 保留旧配置部署时,把原 `.env` 的 `DEEPAGENT_THINKING` 改为 `highest`。使用 `docker run --env-file .env.docker` 时还须重新导出并重建容器,或在创建容器时显式加 `-e DEEPAGENT_THINKING=highest`;旧 env-file 中的 `off` 会覆盖镜像默认值。 Dockerfile 的结果推送开关默认均关闭: ```dockerfile ENV RESULT_PLATFORM_ENABLED=False ENV MAIN_FAN_WORKFLOW_PUBLISH_ENABLED=False ``` | 值 | 批次行为 | |---|---| | `False`(默认) | 报告、结果 JSON、Agent 输入和证据均落本地,不启动结果推送服务。 | | `True` | 启用结果平台服务;新工作流还须显式开启 `MAIN_FAN_WORKFLOW_PUBLISH_ENABLED=True` 才会将本轮结果入队。 | 开启时还需配置 `RESULT_PLATFORM_URL`、鉴权及成功回执字段。未完成结果记录为 `skipped_incomplete`,不会伪装成正式完成结果发送。网络错误不删除本地结果;回执、次数、下一次重试时间保存在业务数据库,按 `推送记录.json` 的 `task_id` 查询业务任务。目录里的状态是入队时快照,以业务任务记录为最新状态。总开关开启后,历史已入队的失败/待重试任务也会按现有队列规则恢复;原来关闭时未入队的本地批次不会自动补推。 网页/工具会话的自动推送还沿用 `MAIN_FAN_AUTO_PUBLISH`;新批次要求总开关和工作流推送开关同时开启。显式提交任务 API 仍须 `publish=true`。`scripts/run_main_fan_batch.py` 是隔离本地工作流入口,始终禁止推送;不传 `--mock-pdf` 时使用真实资料服务。服务器本地测试应保持总开关关闭,避免历史队列在后台恢复推送。 ## 构建和迁移镜像 在项目根目录操作。以下为部署时执行的命令,本次开发没有执行构建或启动。 ```bash docker build -t ventagent:local . docker save -o ventagent.tar ventagent:local ``` 将镜像、`compose.yaml` 和配置放到服务器后: ```bash docker load -i ventagent.tar docker compose up -d --no-build --pull never docker compose logs -f --tail=100 ventagent ``` 镜像架构必须匹配服务器;在 Apple Silicon 为 x86 服务器打包时使用 `docker buildx build --platform linux/amd64 --load -t ventagent:local .`。Compose 使用宿主机网络,供 Linux 服务器部署,服务端口为 8080。 ### 服务器无公网时 联网机器负责构建,服务器只导入、运行。服务器不需要另外安装 Python/npm,也不需要执行 pip/apt 或拉取基础镜像。以上 `--no-build --pull never` 禁止在服务器构建或拉取镜像。运行仍依赖可达的内网模型、取数网关、报告采集服务;推送开启时也须访问结果平台。 先在服务器执行下面的只读命令,确认架构和 Docker 条件: ```bash uname -m docker --version docker compose version ``` `x86_64` 对应构建参数 `--platform linux/amd64`,`aarch64` 对应 `--platform linux/arm64`。将导出的 `ventagent.tar`、`compose.yaml` 和已填写的 `.env` 放到服务器同一个目录,切到该目录执行上面的导入和启动命令。源码和 Dockerfile 不必传到服务器;`.env` 要单独传,权限建议设为 `600`。若服务器上已经有这些文件或卷,保留原有配置和数据。 资料采集服务与 VentAgent 在同一台服务器且只监听回环时,服务器 `.env` 显式设置 `MAIN_FAN_REPORT_API_URL=http://127.0.0.1:19010`;保留既有内网模型/网关地址和凭证。确认 `/home/chief/source_data` 存在且 UID 10001 可读,挂载配置沿用本文说明。启动后会自动运行限量批次,之后按月度规则调度。 如果没有 Docker 或 Compose,需先为目标服务器的发行版、版本和 CPU 架构准备离线安装包及依赖;镜像 tar 不包含 Docker 引擎。此时还需提供 `cat /etc/os-release` 的输出,不能直接混用其他系统的 rpm/deb 包。 Compose 使用非 root 用户(UID/GID 10001);`/home/chief/source_data` 及父目录须允许该用户遍历、读取。不要将整棵服务器目录挂载进应用。命名卷保存应用可写数据。bubblewrap 依赖宿主机开启非特权用户命名空间;Compose 放开容器 seccomp/AppArmor 对命名空间的限制,未授予 `SYS_ADMIN`,应用执行的 Python 仍进入现有禁网沙箱。该运行配置需要在目标服务器验证;若宿主机禁用用户命名空间,需调整宿主策略,程序不会退回无沙箱执行。 ### 不用 Compose,直接 docker run `docker run --env-file` 按字面值读文件,不能直接复用含外层引号、内联注释的 dotenv 文件。模型名变成 `"'Qwen/...'"`、True 开关失效就是这一差异。可在有项目环境的电脑先运行 `.venv/bin/python scripts/export_docker_env.py .env .env.docker`,再把权限0600的 `.env.docker` 传到服务器;脚本不修改原文件、不打印配置值,也不启动应用。 服务器没有 Python 时,已导入的镜像就能转换。下面每条命令都是独立的一整行;在 `.env` 所在目录执行。转换成功后才继续启动,原 `.env` 保留: ```bash umask 077 docker run --rm --pull never --network none --user 0 --mount "type=bind,src=$PWD/.env,dst=/input.env,readonly" --entrypoint python ventagent:local -c 'from dotenv import dotenv_values; import sys; rows=dotenv_values("/input.env"); assert all(v is None or not any(c in v for c in "\r\n\x00") for v in rows.values()), "配置含有不支持的多行值"; sys.stdout.write("".join(f"{k}={v}\n" for k,v in rows.items() if v is not None)); print("配置转换完成", file=sys.stderr)' > .env.docker chmod 600 .env.docker ``` 下面启动命令沿用持久化卷、只读报告挂载和现有沙箱配置,显式启用主通风机及网关。网关 URL/token、模型凭证需已在原 `.env` 配置;不输出凭证。资料服务和应用同机时用回环地址,否则将 `MAIN_FAN_REPORT_API_URL` 改成实际地址。仅首次创建容器时执行;已有同名容器时先停止并删除容器,保留命名卷。 ```bash docker run -d --name ventagent --pull never --init --restart unless-stopped --network host --env-file .env.docker -e DEEP_AGENT_ENV_PRIORITY=process -e MAIN_FAN_ENABLED=True -e SHANXI_GATEWAY_ENABLED=True -e MAIN_FAN_LOCAL_BATCH_ENABLED=True -e MAIN_FAN_REPORT_API_URL=http://127.0.0.1:19010 --security-opt seccomp=unconfined --security-opt apparmor=unconfined --cap-drop ALL --log-driver json-file --log-opt max-size=20m --log-opt max-file=5 -v ventagent-data:/app/data -v ventagent-outputs:/app/outputs -v ventagent-logs:/app/logs --mount type=bind,src=/home/chief/source_data,dst=/data/source_data,readonly ventagent:local ``` 旧镜像曾在预热时创建 `/app/workspace` 和 `/app/conversation_history`,非 root 用户无权创建源码根目录的子目录。新源码已修正:只读技能,实际工作区及历史使用 `/app/data/workspaces` 持久化卷。暂不重打旧镜像时,在上面的启动命令额外加 `--tmpfs /app/workspace:rw,uid=10001,gid=10001,mode=0700 --tmpfs /app/conversation_history:rw,uid=10001,gid=10001,mode=0700`,兼容旧初始化;这两个旧共享目录不存放实际会话数据。 修改 `.env` 后重新导出 `.env.docker` 并重新创建容器,单纯 `docker restart` 不更新容器环境。以 env-file 注入时不要求存在容器内 `/app/.env`;缺少这个文件不代表注入失败,模型名是否无引号、扩展是否启用及启动批次日志才是有效检查项。 ## 报告目录与接口 ```text 服务器 /home/chief/source_data(只读挂载) → 容器 /data/source_data/{矿名全称}/{年}/通风机性能测定报告/*.pdf → /app/data/workspaces/.../workspace/exploration/{workflow_id}/reports/report-N.pdf → 给Agent的数据.json 的 reports[].path ``` 默认文件服务 URL 为 `http://10.244.3.155:19010`。快速调用文档说明服务仅监听宿主机回环,所以 Compose 的默认值为 `http://127.0.0.1:19010` 配合宿主机网络;若已开放内网监听,可在 `.env` 设置 `MAIN_FAN_REPORT_API_URL=http://10.244.3.155:19010`。目录挂载不能解决 API 监听问题。 固定代码依次调用 `/mines?with_tree=0` 核对同一行 dept_id 和矿名全称、`/doc-types/sync`、`/doc-types` 动态获取资料类型号、`/crawl`,再轮询 `/crawl/{task_id}`。每次一个矿、一种类型、一期,多个通风井复用矿级采集结果。`MAIN_FAN_REPORT_PERIOD` 留空取批次截止月份;可显式设为 `2026-03`,不写死示例类型号 129。 快速文档未定义返回附件路径清单,因此完成后只读取约定的**同矿、同年、同资料类型**目录内的直接 PDF 文件;它们可能包括往期原件,不能声称全是本期新增文件,报告日期及通风井/风机归属仍核对正文。当前不扫描子目录,也不解压附件;非 PDF 不送入这条 PDF 分析链。目录不存在则报告集合为空;存在但不可读、文件非法、超限、采集 `partial/failed` 都明确失败,不静默回退到测试 PDF。`report-source/task.json` 保存任务 ID 和响应供恢复、排查,`files.json` 保存原件相对路径。哈希使用复制后的实际内容,命中同矿同井档案则复用。 报告查询示例(Agent 虚拟路径,宿主实际文件位于持久化 workspace): ```json {"reports":[{"path":"/workspace/exploration//reports/report-1.pdf","mode":"analyze","sha256":"<实际文件hash>","page_count":12,"binding":"mine_directory_content_unverified"}]} ``` ## Agent 独立执行 所有通用和业务 Agent 均禁用子 Agent:不挂载扩展子 Agent,并在实际模型的 HarnessProfile 中显式关闭框架默认添加的 `general-purpose`。模型不再获得 `task` 工具,系统提示词也不再包含委派指引。`GET /api/model` 的 `agent.subagents` 返回空列表,`agent.subagents_enabled` 为 `false`。 主通风机批次、业务任务 API 和网关触发的独立业务入口保留,由当前业务 Agent 自行读取资料、执行 Python 并提交报告。通用聊天入口不再通过子 Agent 分发专业业务。此项为源码中的全局禁用策略,没有启用子 Agent 的环境变量;更新镜像后重新创建容器生效。 ## 日志与排查 默认同时输出 stdout 和 `/app/logs/ventagent.jsonl`,每个文件 20MB,保留 5 个轮转文件;Docker stdout 也限制大小。设置 `LOG_LEVEL=DEBUG` 可查看各接口耗时、每日时序查询及 PDF 复制记录;INFO 保留阶段汇总、报告 hash 复用数量和模型 token 用量。 检索字段包括 `batch_id`、`dept_id`、`shaft_id`、`workflow_id`、`crawl_task_id`、推送 `task_id` 和 `event`。错误带堆栈,日志沿用凭证脱敏,不记录 PDF 内容和整批响应。模型调用前失败时,本地 `failure.json` 和 `report-source/task.json` 可辅助定位。 ### 模型返回 429 concurrent_limit 下面的 SDK 重试说明适用于原 Agent 入口。新批次固定工作流只发一次 Qwen 生成请求,429 也直接记录失败,不受 `OPENAI_MAX_RETRIES` 影响,不会重新执行或多次研判。 这表示模型服务的并发名额已满。主通风机批次队列按矿井、通风井串行执行,但模型账号或服务还可能被网页对话、其他实例或其他调用方占用;仅凭应用日志无法确定占用者。 模型请求通过 `OPENAI_MAX_RETRIES` 配置 SDK 退避重试次数,默认 8 次(不含首次请求),可设为 0 禁用。没有服务端等待提示时,当前锁定 SDK 的 8 次重试累计等待约 30–40 秒,另加各次请求本身耗时;存在受支持的 `Retry-After` 时按该提示等待。重试覆盖尚未成功建立响应的 HTTP 请求,已开始的流式响应失败仍会明确报错,不重放已生成内容或已执行工具。非临时错误继续按 SDK 规则直接失败。 这项设置需要使用包含新代码的镜像;仅给旧镜像添加环境变量不会改变写死的 2 次重试。配置变更后重新创建容器。若等待后仍持续返回 `concurrent_limit`,需模型服务方根据请求 ID 核查并发配额和占用;增加重试次数不能增加上游名额。 ### 模型以 tool_calls 结束,但没有工具调用 2026-10-22 的现场记录中,已失败矿井的报告采集和 240 个历史日段均已完成;首次模型响应却只有 `finish_reason=tool_calls`,没有工具名和参数。直接请求同一模型接口确认:流式模式的 170 个事件全部为 `delta={}`;非流式模式返回完整的工具调用及实际用量。这是模型接口的流式工具返回异常,清空会话或增加 HTTP 重试次数不能补回这些字段。 镜像默认设置 `OPENAI_TOOL_CALL_STREAMING=False`,带工具的模型调用读取完整 JSON 响应,由 LangChain 正常解析、执行工具;应用的 SSE 消息和进度事件保留。模型就绪日志中的 `tool_call_streaming=False` 可确认开关生效。源码运行可配置同名环境变量;改动后重新创建容器或重启应用以重建 Agent。接入已确认支持完整工具流的模型服务后可设为 `True`。 ### 工具执行后出现 UnprocessableEntityError(HTTP 422) 同一模型网关对助手消息的 `content` 只接受字符串或列表。LangChain 在助手只有工具调用时会发送 `content: null`,因此首次请求可以返回工具调用,带上工具结果的下一次请求却被网关拒绝。直接请求复现了 `messages[1].content` 类型校验失败;只将该字段改为 `""`,同一请求即返回 HTTP 200 和完整工具调用。 配置 `OPENAI_BASE_URL` 时,模型工厂使用请求格式适配类,在序列化后将空工具调用消息的 `content: null` 改为 `content: ""`。适配覆盖同步、异步和流式请求,保留工具名、参数、调用 ID 以及工具返回内容,不修改已有会话和检查点。此问题不需要清空记忆;需使用包含适配代码的新镜像并重新创建容器。 ### 研判结果和所用数据放在一起 每个通风井的结果目录为 `/app/outputs/main_fan_batches/{批次ID}/{deptId}/{shaftId}/`,其中保留: ```text 报告.md 结果.json 给Agent的数据.json 给Qwen的数据.json PDF提取结果.json Qwen原始响应.json 待审阅报文.json 研判数据.zip 数据清单.json 来源与覆盖.json 推送记录.json ``` `研判数据.zip` 包含本次实际绑定的输入、PDF 原件、历史数据证据压缩包、逐页文字及 OCR 图片/请求/响应、唯一一次 Qwen 的完整请求和原始响应。`给Qwen的数据.json` 是实际完整研判输入,`给Agent的数据.json` 兼容保留原监测预览。压缩包内的 `workspace/` 对应会话 `/workspace/`;原始日段和完整采样继续放在 `history-evidence.zip` 内。 `数据清单.json` 记录原路径、压缩包内路径、大小、SHA256、矿井和工作流归属。原始输入按分析时的指纹严格核验;被脚本覆盖或删除的中间版本会列在 `unavailable_intermediates`,不会用当前同名内容冒充旧内容。先完成结果和数据归档,再根据推送开关投递;归档失败后恢复只重试导出,不重新调用 Agent。已有数据还保留在矿级工作区,方便排查中断任务。 ## 每月启动、分20天研判 在服务器 `.env` 中配置: ```dotenv MAIN_FAN_LOCAL_BATCH_ENABLED=True MAIN_FAN_BATCH_RUN_ON_STARTUP=False MAIN_FAN_BATCH_STARTUP_MAX_MINES=2 MAIN_FAN_BATCH_CRON=0 2 1 * * MAIN_FAN_BATCH_SPREAD_DAYS=20 MAIN_FAN_BATCH_DEPT_IDS=all MAIN_FAN_BATCH_ALLOW_ALL=True MAIN_FAN_BATCH_MAX_MINES=0 ``` 北京时间每月 1 日 02:00 查询并冻结当月正常生产煤矿清单,按清单顺序均分为 20 份,第 1~20 天每天 02:00 投递一份,份内仍逐矿串行处理。总矿数除以 20 的余数分到前几天,每日矿数最多相差 1;例如 403 矿为前 3 天每天 21 矿、后 17 天每天 20 矿。少于 20 矿时前 N 天每天 1 矿,后续空份跳过;没有矿井时只保存空计划。 `MAIN_FAN_BATCH_SPREAD_DAYS` 支持 1~28,未配置时为 1,保留原有每月整批执行方式;部署模板为 20。月度触发仍由 `MAIN_FAN_BATCH_CRON` 控制,日调度取其中相同的小时、分钟,每天检查已有计划,不会重新获取清单或重新划分。月度计划保存在 `MAIN_FAN_BATCH_OUTPUT_DIR/monthly_plans/{plan_id}.json`,记录清单快照、每日矿井、原计划时间和批次标识。重复触发同月同范围时复用原计划;改变天数不会重新划分已有计划。每个每日批次在实际执行时冻结自己的监测截止时间,默认回查当时最近 40 天。 每个北京时间自然日最多领取一份,完成后重复触发也不会提前领取下一份。服务重启会恢复已投递但中断的份额,复用原批次目录和已完成结果;恢复当天不再增加新份额。上一份超过一天仍未结束时,不堆积后续批次。停机漏掉的份额在后续日调度按原顺序每天一份顺延,可能超过 20 日;跨月时先处理旧计划,新旧计划共用每日限额。某份执行结束但有失败矿井(`partial`/`failed`)时保留失败记录,下一天继续下一份,不自动无限重试失败矿井。 仅月度触发器创建新计划;月中首次部署或停机错过月度触发时,启动和日调度不会自动补建整月计划。清空 `MAIN_FAN_BATCH_CRON` 同时关闭月度触发、日调度和已中断份额的启动自动恢复。日志 `batch.monthly.plan.created` 记录每日矿数,`batch.monthly.day.queued` 记录实际投递日期、份号和批次标识。 `MAIN_FAN_BATCH_MAX_MINES=0` 不额外限制实际批次矿数;设为正数时,分日模式按每份矿数校验,超过上限就报错,不截断清单。`MAIN_FAN_BATCH_DEPT_IDS` 也可指定逗号分隔的部门编号,按该范围均分。全矿模式仍须 `MAIN_FAN_BATCH_ALLOW_ALL=True`。 如需独立的启动研判,可另设 `MAIN_FAN_BATCH_RUN_ON_STARTUP=True`,服务就绪后从同一候选范围取前 `MAIN_FAN_BATCH_STARTUP_MAX_MINES` 个矿,每次重启都会额外跑一批,与月度计划分别计数。该数量必须大于 0,也受正数 `MAIN_FAN_BATCH_MAX_MINES` 保护上限约束。分日部署建议关闭独立启动批次;`False` 仍允许恢复已有月度计划中已投递的中断份额。 改完 `.env` 需使用包含分日代码的新镜像并重新创建容器才加载配置。独立本地预览脚本不启动调度器,因此不会额外触发自动批次。