fix(#188): 推理 API 路径规范化,消除双 /v1 前缀对 nginx rewrite 的隐式依赖 (#192)

bot_qa 复审通过(bot_dev3 代发)。PR #192 引用未同步(head 滞留 5667fad),
按 feature/issue-188 分支最新 9994fb7 手工合并。
包含:nginx 无 URI 透传 + location = /health、infer.ts 路径口径统一、
latencyMs 实测、cockpit 健康检查走封装。

⚠️ 部署:UI 产物与 nginx 配置必须同批上线(新旧路径口径互不兼容)。
This commit is contained in:
2026-08-06 17:26:05 +08:00
6 changed files with 60 additions and 14 deletions
+7 -1
View File
@@ -138,9 +138,15 @@ docker exec -it fba_postgres psql -U postgres -d fba # 进数据库
│ · 6 模块 → views/iaop/*(cockpit/chat/studio/admin)
│ · 推理通道 /v1/ → 127.0.0.1:30800(推理服务,独立于 /fba)
├── /fba/ → 127.0.0.1:8001 fba_server(认证/RBAC/系统管理)
└── /v1/ → 127.0.0.1:30800 推理服务(iAOP LLM 网关)
└── /v1/ → 127.0.0.1:30800 推理服务(iAOP LLM 网关,**不去前缀直接透传**)
```
### 推理通道路径约定(issue #188)
- 推理服务真实路径即 `:30800/v1/*`(`/v1/models`、`/v1/chat/completions`、`/v1/health`)。
- nginx 反代 `/v1/` 用 `proxy_pass http://127.0.0.1:30800/`(带末尾 `/`,**不去前缀**,直接透传到后端的 `/v1/...`)。
- 前端 `api/iaop/infer.ts` 的 `BASE_URL` 为空串,调用方统一写 `/v1/models`、`/v1/chat/completions`、`/v1/health`,**不再出现 `/v1/v1` 双前缀**,也不依赖 nginx rewrite 去前缀的巧合耦合。
- nginx location 片段见 `deploy/fba/nginx-fba.conf`。
### 组件落点
- 受控源码:`fba-ui-src/`(fastapi-best-architecture-ui 工程,apps/web-antdv-next)
- 构建流水线:`build-ui.sh`(pnpm install → build → artifacts/fba-ui-dist.tar.gz)
@@ -1,15 +1,20 @@
/**
* iAOP LLM 网关客户端(Epic #163 / issue #166 [A3])
*
* 对齐旧 `web/` 各模块的 INFER_BASE(nginx 反代 /v1/ → 推理服务 :30800):
* GET /v1/health —— 健康巡检
* 对齐旧 `web/` 各模块的 INFER_BASE(nginx 反代 → 推理服务 :30800):
* GET /health —— 健康巡检(serve.py 探针路由无前缀)
* GET /v1/models —— 模型列表
* POST /v1/chat/completions —— OpenAI 兼容对话(dry-run 占位 / 真实推理)
*
* 推理服务为 iAOP 独立通道(不要求 FBA JWT),故用原生 fetch 封装,
* 统一超时 / 错误信息 / 结果结构;FBA requestClient 仅用于 /fba/* 接口。
*
* issue #188(bot_qa 审核修正):nginx 反代使用不带 URI 的 proxy_pass
* 原样透传,故 BASE_URL 为空串,调用方按后端真实路由写
* /v1/models、/v1/chat/completions、/health(健康探针无 /v1 前缀),
* 避免出现 /v1/v1 双前缀对 nginx rewrite 的隐式耦合。
*/
const BASE_URL = '/v1';
const BASE_URL = '';
const TIMEOUT_MS = 15000;
export interface InferResult {
@@ -92,12 +97,14 @@ export async function chatCompletion(
/** 便捷:返回纯文本(B 系列页面统一使用) */
export async function ask(prompt: string, opts?: Parameters<typeof chatCompletion>[1]): Promise<InferResult> {
// issue #188:用 performance.now() 实测耗时,替代原先 latencyMs: 0 硬编码
const startedAt = performance.now();
const resp = await chatCompletion(prompt, opts);
const msg = resp.choices?.[0]?.message?.content || '';
return {
text: msg,
meta: (resp.meta as Record<string, unknown>) || {},
backend: String((resp.meta as Record<string, unknown>)?.backend || 'gpu'),
latencyMs: 0,
latencyMs: Math.round(performance.now() - startedAt),
};
}
@@ -68,7 +68,7 @@ async function send() {
messages.value.push({ role: 'assistant', content: r.text, meta, citations: hits });
} catch {
offline.value = true;
messages.value.push({ role: 'assistant', content: '推理服务请求失败,请检查网关(/v1/health)。' });
messages.value.push({ role: 'assistant', content: '推理服务请求失败,请检查网关(/health)。' });
} finally {
loading.value = false;
scrollBottom();
@@ -45,7 +45,7 @@ async function submit() {
<a-button type="primary" :loading="loading" @click="submit">查询</a-button>
</div>
<a-alert v-if="offline" type="warning" show-icon style="margin-top: 8px">
<template #message>推理服务离线 / dry-run 占位:请检查后端推理服务(/v1/health)</template>
<template #message>推理服务离线 / dry-run 占位:请检查后端推理服务(/health)</template>
</a-alert>
<div v-if="answer" class="nl-answer">{{ answer }}</div>
</div>
@@ -8,6 +8,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { fetchHealth } from '#/api/iaop/infer';
import { loadCockpitPlan } from '#/api/iaop/templates';
import AlarmPanel from './components/AlarmPanel.vue';
@@ -71,13 +72,10 @@ onUnmounted(() => {
const inferOnline = ref(false);
async function refreshHealth() {
try {
const resp = await fetch('/v1/health', { signal: AbortSignal.timeout(3000) });
const d = await resp.json();
inferOnline.value = d.status === 'ok' || d.status === 'dry-run';
} catch {
inferOnline.value = false;
}
// issue #188:健康检查改走 infer.ts 的 fetchHealth 封装,路径口径统一(/health,无前缀)
const d = await fetchHealth();
const status = d?.status;
inferOnline.value = status === 'ok' || status === 'dry-run';
}
</script>
+35
View File
@@ -64,3 +64,38 @@ location /fba-admin/ {
add_header Cache-Control "no-cache";
}
}
# =============================================================================
# 推理通道(iAOP LLM 网关)
#
# issue #188(bot_qa 审核修正):推理服务真实路径为
# POST /v1/chat/completions、GET /v1/models(带 /v1 前缀)
# GET /health(健康探针,**无 /v1 前缀**,见 deploy/k8s/docker/serve.py)
#
# 注意 nginx 语义:proxy_pass 带 URI 部分(含末尾 /)时会把 location 匹配
# 前缀**替换**掉(即"去前缀",同上方 /fba/ 块用法);要"原样透传"必须
# 使用不带 URI 的 proxy_pass(无末尾 /),请求 URI 完整转发给后端。
# 前端 infer.ts BASE_URL 为空串,统一写 /v1/models、/v1/chat/completions、/health。
# =============================================================================
location /v1/ {
# 不带 URI(无末尾 /):/v1/models → :30800/v1/models 原样透传
proxy_pass http://127.0.0.1:30800;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 20m;
proxy_read_timeout 120s;
}
# 健康探针无前缀,单独精确透传:/health → :30800/health
location = /health {
proxy_pass http://127.0.0.1:30800;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}