前端转 AI · 第 06 篇|P5:产品化——Agent、多模态、私有化、计费
本文是系列第 6 篇,也是这个系列的收官篇。上一篇 P4:企业能力——多租户、SSO、审批流、连接器 把这个知识库升到了"企业级";再往前是 P3:工程化、P2 质量、P1 MVP、P0 地基;整套六阶段路线见 总纲。
P4 收尾时我留了一句"下一篇 [P5 产品化](待续) 会聊 Agent 多步检索、多模态、私有化与计费"。这篇就是那个"待续"——但它和前几篇有个本质区别:前 5 篇每一篇都交付了一个能跑的增量产品,而 P5 是"持续演进"阶段,没有"做完"的那一天。
阅读约定:
⚠️ 易错点都是真实踩过的坑,紧跟的✅ 解决方案可以直接照抄。代码已脱敏,但结构和参数与真实实现一致。本文刻意不美化落地度——哪块是真做了、哪块只有底座、哪块压根没碰,都会标清楚。
0. 这篇的定位:P5 不是"功能清单",是"持续演进的纪律"
先泼一盆冷水。很多人一到 P5(产品化)就容易变成"课程收藏家"——看了一堆 Agent 教程、收藏了十个多模态仓库,但产品一点没动。
⚠️ 路线级易错点:到了产品化阶段,容易陷入"这个也想要、那个也该做",最后什么都没落地。
✅ 解决方案(来自总纲,我严格执行了):每一项立项前先写一句用户故事和一条验收标准,写不出来就说明现在不需要做它。 所以这一篇我不按"功能清单"写,而按"用户故事 + 真实落地度"写。
P5 我给自己列了四块,每一块都先问"谁会用、用了解决什么":
| 能力块 | 用户故事(一句话) | 验收标准 |
|---|---|---|
| Agent | "问复杂问题时,系统能自己多查几次、调工具算数、最后给带引用的答案" | 多步检索可观测、工具调用有白名单、答案强制带引用/拦敏感 |
| 多模态 | "扫描件 PDF、带表格的制度文件也能问答" | OCR 入库可被检索;表格问题走精确答、不瞎编数字 |
| 私有化 | "客户机房不能出公网,要跑本地模型" | 无云 API Key 也能注册→建库→入库→问答闭环 |
| 计费 | "老板问这个月花了多少 token、哪个租户超量了" | 用量可统计、文档/连接器有硬上限、能看 7 日图表 |
下面逐块拆,并如实标注落地度。先放一张全局图,看这四块挂在哪:
┌──────────────────────────────────────────────┐
│ Next.js 管理台 │
│ ChatPanel( na|advanced|agent ) │
│ IngestProfile(OCR/表格开关) / UsageChart │
└───────────────────────┬──────────────────────┘
│ Cookie(httpOnly)
┌───────────────────────▼──────────────────────┐
│ FastAPI (api) │
│ ├─ orchestration/agent AgentRuntime+planner │
│ │ tools/ 白名单: retrieve|calculate| │
│ │ summarize|table_query │
│ ├─ services/ocr Tesseract(可插拔) │
│ ├─ services/table_* 表格抽取+精确问答 │
│ ├─ services/providers ollama|openai_compat │
│ └─ services/usage/quota 计量+硬配额+限流 │
└───┬────────────────┬───────────────┬──────────┘
│ │ │
┌───────▼─────┐ ┌───────▼──────┐ ┌───────▼──────┐
│ PostgreSQL │ │ Chroma │ │ Ollama(可选) │
│ UsageRecord │ │ │ │ qwen2.5:7b │
│ 配额字段 │ │ │ │ nomic-embed │
└─────────────┘ └──────────────┘ └──────────────┘
一句话概括 P5 的边界:能力都长出来了,但"默认路径"仍然保守——Agent 默认单步、OCR 默认关、私有化要切 provider、计费只到计量层。这是有意的"渐进式产品化",不是技术债。
1. Agent:多步检索、工具调用、护栏
1.1 编排层到这一步才真正长成
总纲第六节早就剧透过:P0/P1 两条流是硬编码直线,P2 才建 orchestration/ 把问答链路节点化,P3 把入库链路搬成工作流,P4 把流配置化,"P5 才轮到 Agent 多步编排"。现在终于到这一步。
这一篇的 Agent 不自己造引擎。编排内核是 AgentRuntime,跑的是 plan → act → observe 事件循环:
# backend/app/orchestration/agent/runtime.py(节选)
class AgentRuntime:
def __init__(self, planner=None, max_steps=None, *,
require_citations=False, block_sensitive=False):
self.planner = planner or default_planner # ← 注意默认 planner
self.max_steps = max_steps or settings.agent_max_steps # 默认 6
def stream(self, message, history, ctx):
yield {"type": "agent_step", "step": 0, "phase": "plan"}
for step in range(1, self.max_steps + 1):
decision = self.planner(message, history, observations)
if decision.get("type") == "answer":
err = check_answer_guards(content, citations, ...)
if err: yield {"type": "error", "content": err}; return
yield {"type": "done"}; return
result = run_tool(decision["tool"], decision["args"], ctx)
observations.append(result.observation)
citations.extend(result.citations)
每个 step 都 yield 一个 agent_step 事件(plan / act / observe + elapsed_ms),前端 AgentStepTimeline.tsx 只读渲染成"这次回答走了哪些节点、各花了多久"——这就是总纲说的"只读流程图",成本只有 Dify 画布的 1/50,没做拖拽画布。
1.2 真实落地度:三样东西是扎实的
① 工具白名单(绝不裸奔)。 tools/registry.py 只放行 4 个工具:retrieve / calculate / summarize / table_query。retrieve_tool 强制 session_kb_id,拒绝跨库 kb_id——Agent 再怎么"想",也读不到别人的知识库。calculate_tool 用 ast 做安全求值,只放行 + - * /,不 eval 任意表达式。
② 护栏(默认双开)。 guards.py 的 check_answer_guards 默认开 require_citations(无引用直接 insufficient evidence)和 block_sensitive(PII 正则 + 敏感词)。config.py 里:
agent_max_steps = 6
agent_require_citations = True
agent_block_sensitive = True
agent_sensitive_words = "密码,secret,api_key,credential,token"
③ 前端可选模式。 ChatPanel.tsx 的聊天模式下拉里有 agent 这一项,用户自己切;不是偷偷替换默认链路。
1.3 ⚠️ 易错点:默认是单步,多步 ReAct 没接默认路径
这是写这篇时我最想讲清的一个"诚实点"。看 default_planner:
# backend/app/orchestration/agent/planner.py
def default_planner(message, history, observations):
"""Default: one retrieve then answer (simple fallback without LLM)."""
if not observations:
return {"type": "act", "tool": "retrieve", "args": {"query": message}}
return {"type": "answer",
"content": observations[-1] if observations else "No evidence found."}
没有任何 observation 就 retrieve 一次,有了 observation 直接 answer——也就是说生产默认是"带护栏的单步 retrieve",跟 P1 的 naive_rag 行为几乎一样。而真正的 LLM 多步规划器 llm_planner 其实已经写好了:
def llm_planner(message, history, observations, *, cfg=None, llm=None):
"""Production planner: ask LLM for structured next step JSON."""
llm = llm or build_llm(cfg)
prompt = f"{_PLANNER_SYSTEM}\n\nUser question: {message}\n\nObservations:\n{obs_block}\n\nNext step JSON:"
raw = str(llm.complete(prompt)).strip()
parsed = _extract_json(raw)
if parsed and parsed.get("type") in ("act", "answer"):
return parsed
return {"type": "answer", "content": raw or "Unable to plan next step."}
⚠️ 易错点:
llm_planner函数完整、单测用scripted_planner也验证过循环逻辑,但全仓除自身定义外没有任何生产调用——chat_stream构造AgentRuntime时不传planner,于是默认走default_planner。结果是:多步 ReAct 能力"已具备但未默认开启",没有开关、没有文档入口、没有测试覆盖。
✅ 解决方案:要体验真正的多步 Agent,把 planner 注入即可(最小改动,不用改运行时):
# 在 routes_chat.py 的 agent 分支里
from app.orchestration.agent.planner import llm_planner
from app.orchestration.agent.runtime import AgentRuntime
runtime = AgentRuntime(
planner=llm_planner, # ← 关键:注入 LLM 规划器
require_citations=True,
block_sensitive=True,
)
我把它明确写成"待接开关"而不是藏着——因为生产默认单步是有意的:多步会多烧 token、多一次延迟,且 LLM 规划本身会出错。先让单步稳稳跑,多步作为"可开启的增强",比"默认就多步、线上抖动"稳得多。呼应 P4 易错点 16:别把 IngestProfile 当可视化 flow graph 编排,范围会爆炸——Agent 也一样,先有白名单+护栏的"可控多步",再谈智能。
验收点:test_agent_runtime.py(plan→act→observe 循环、max_steps 截断)、test_guards.py(无引用拦截、敏感词拦截)、test_tools_registry.py(跨库 retrieve 被拒、calculate 只放行四则运算)均通过。
2. 多模态:OCR + 表格问答(图片理解未做)
2.1 OCR:把 P0 就埋的"可能是扫描件"提示变成真能力
还记得吗?P0 第 677 行我写过:"报错信息里写清『可能是扫描件、需要 OCR』……扫描件的 OCR 留到 P5。"P1 入库校验空文本也会报 "may be a scanned PDF needing OCR",但那时 OCR 本身没做。现在 P5 把它补上了。
OCR 走的是可插拔引擎协议,测试用 FakeOcr 替身,生产用 Tesseract:
# backend/app/services/ocr/base.py
class OcrEngine(Protocol):
def ocr_pdf_pages(self, path: Path) -> list[tuple[int, str]]:
"""Return (1-based page number, recognized text) for each page."""
# backend/app/services/ocr/tesseract_engine.py(节选)
class TesseractOcrEngine:
def ocr_pdf_pages(self, path):
doc = fitz.open(path) # PyMuPDF 逐页渲染成图
for i, page in enumerate(doc, start=1):
pix = page.get_pixmap(dpi=300)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
text = pytesseract.image_to_string(img, lang="chi_sim+eng")
yield (i, text)
入库接线在 ingest.py 的 _resolve_ocr_engine + ocr_enabled 开关:空文本且开了 OCR → 走 Tesseract 回填;仍空才报"可能是扫描件"。
⚠️ 易错点:OCR 依赖宿主机装 Tesseract 二进制 +
pip install pymupdf pytesseract pillow,新手一跑就TesseractNotFoundError。更坑的是——有些人图省事在缺依赖时静默降级,结果扫描件被当空文件、悄悄丢知识。
✅ 解决方案:本项目缺依赖时显式报错、不静默降级(这点我专门做对了)。部署文档里写清安装命令:
# Windows(示例,Linux/macOS 用对应包管理器)
# 1) 装 Tesseract 主程序并加入 PATH
# 2) 装中文包(chi_sim)
# 3) pip install pymupdf pytesseract pillow
⚠️ 易错点(质量预期):中文扫描件用默认 Tesseract 准确率有限,尤其表格线、印章、手写体。
✅ 解决方案:文档明确把 PaddleOCR 列为后置、非必装;先用 Tesseract 把"能 OCR"跑通,精度不够再换引擎——不要在第一天就纠结识别率,先把链路打通。
2.2 表格问答:结构化精确答,不烧 LLM
制度文件里全是"附表三:各职级年假天数"。这种问题让 LLM 从 chunk 里"读数字"极易算错。本项目的解法是把表格切成独立 chunk + 结构化工具短路:
# backend/app/services/table_extract.py
def extract_table_nodes_from_pages(pages):
# 把空格/管道对齐的表格切成 type="table" 的 chunk,带 schema_hint
...
# backend/app/services/table_answer.py
def try_table_short_answer(question, table_chunks):
# 命中表格 chunk 时,不走 LLM,直接精确解析返回
# 支持:求和 / 第 N 行 / 按列查
...
naive_rag.py 里接了 try_table_short_answer:一旦检索命中的是表格 chunk,就走结构化解析,双路短路——既准又省 token。Agent 侧也有 table_query_tool 复用同一套逻辑。
⚠️ 易错点:把"表格"当普通文本 chunk 塞进向量库 → 数字被切散、行列关系丢失,问答时 LLM 凭印象编数("大约 10 天"变成"15 天")。
✅ 解决方案:表格独立成type="table"chunk + 结构化查询工具。前端IngestProfileForm.tsx里 OCR 与"表格结构化抽取"是两个独立 checkbox,按需开。
2.3 图片理解(vision):明确未做
必须说清楚:图片/多图语义理解(让模型"看"图回答)目前全仓没有任何实现。grep vision|image_understand|图片理解 只命中模型名配置字符串,没有任何视觉理解代码。
所以本项目的"多模态"范围是文本化(OCR)+ 结构化表格,不含图像语义理解。这不是疏忽,是范围选择:图片理解通常要 vision 模型(如带视觉的本地模型),在 L1 离线栈里成本和可靠性都还没到能承诺的程度。写进路线图、暂不立项。
3. 私有化 / 离线部署:L1 已可跑通
3.1 docker-compose.offline.yml:无云 Key 闭环
这是 P5 里最实在、最容易演示成功的一块。客户说"我们机房不能出公网",过去你会卡在"模型在云上"。现在用 docker-compose.offline.yml,整套闭环跑本地 Ollama,不需要任何外部 API Key:
# docker-compose.offline.yml(节选)
services:
ollama:
image: ollama/ollama
ports: ["11434:11434"]
api:
environment:
- LLM_PROVIDER=ollama
- EMBEDDING_PROVIDER=ollama
- OLLAMA_BASE_URL=http://ollama:11434/v1
- OLLAMA_LLM_MODEL=qwen2.5:7b
- OLLAMA_EMBED_MODEL=nomic-embed-text
- OPENAI_API_KEY=ollama # 占位即可,不指向真实云服务
worker:
environment: # 与 api 同套 ollama 配置
- LLM_PROVIDER=ollama
- ...
provider 切换在 services/providers.py 真实生效——build_llm 用 chat_model_name / chat_api_base / provider_api_key,ollama 和 openai_compatible 两套走不同分支:
⚠️ 易错点:Ollama 没起来(镜像没 pull、端口被占)时,API 卡在等模型响应,前端转圈不报错,排查极痛苦。
✅ 解决方案:providers.py里LocalModelUnreachableError+maybe_raise_local_model_error,Ollama 不可达时 API 直接返回 503 + 清晰提示,已在routes_chat.py捕获。第一次跑要先ollama pull qwen2.5:7b(需联网一次),运行期不再依赖外部 LLM——这正是"私有化"的边界。
离线手册 docs/deploy/offline.md 给了硬件建议、拉模型脚本、IdP 降级(L1 用本地账号、不启 Keycloak)、性能对照表。Lean 一句话:L1 = 本地账号 + 本地模型,能跑通"注册→建库→上传→本地 embedding 入库→本地 LLM 问答"完整闭环。
3.2 边界与刻意延后
⚠️ 易错点:把"能跑通离线"误宣传成"满足等保/气隙交付",客户一验收就翻车。
✅ 解决方案:文档自陈把 L2(气隙镜像仓库、离线 apt/pip 源、密钥托管) 与 L3(完整内网 IdP 交付包、多 AZ、等保) 列为"不在 L1 交付",需另开规格。私有化的"能跑"已达,政企级"全交付"未达——能说清"交付到哪一层",比拍胸脯强。
注意:离线 compose 里 Keycloak 是 NOT included 的(注释写明"用本地账号")。要企业 SSO 还是得走 P4 的 OIDC 方案,离线场景先不接 IdP。
4. 计费:计量 + 硬配额底座在,但"商业化"还早
4.1 已落地的部分(骨架是好的)
用量表结构很清楚:
# backend/app/models/orm.py
class UsageRecord:
tenant_id / user_id / kb_id
tokens_in / tokens_out / cost
usage_service.py 三件套:record() 写行、compute_cost() 按 MODEL_PRICING 算钱、stats_last_7_days() 给管理台聚合。前端 UsageChart.tsx + admin/page.tsx 渲染 7 日用量图,/api/admin/stats 出数据。
配额是硬上限且部分已接线:
# backend/app/services/quota_service.py
def assert_can_add_document(kb_id, ...): ... # ingest.py:558 已调用
def assert_can_add_connector(kb_id, ...): ... # connector_service.py:99 已调用
def assert_daily_token_budget(tenant_id, ...): ... # ⚠️ 定义了,全仓零调用
限流也真实生效:core/rate_limit.py(slowapi)auth=5/minute、api=20/minute。
4.2 ⚠️ 两个真实缺口(写作必须如实写)
⚠️ 缺口 1——流式对话不计量:
usage_service.record只在同步chat端点调用;而前端实际走的是POST /api/chat/stream,该流式端点完全没有record,flows 内部也不记录。结论:用户真实对话产生的 token 用量基本没落库,管理台图表大概率长期空/不全。
✅ 解决方案(路线):在 SSE flow 的结束事件里补一次record(用extract_token_usage从 response 元数据取prompt/completion tokens),一个拦截点就能堵上。
⚠️ 缺口 2——日 token 配额从未接线:
assert_daily_token_budget写了却没人调用,形同虚设。orm.py里max_daily_tokens=1_000_000这个默认值只是个标量列。
✅ 解决方案(路线):把assert_daily_token_budget接到chat_stream入口,tenant_id维度统计当日已用、超了直接 429。代码量很小,只是当时没排进验收。
4.3 ❌ 没有"套餐 / tier"体系
这点要讲透,免得被当成"已上线计费系统":
- 没有 免费 / 专业 / 企业 这类分层;
- 没有 订阅、出账、支付、发票;
compute_cost只是把一个数写进cost字段,没有对应的账单、配额档位、超量策略。
所以所谓"计费"目前是"计量 + 两个硬上限(文档数/连接器数)+ 限流",离"商业化计费系统"还差套餐体系、支付对接、超量策略三座大山。把它叫"用量可观测与配额护栏"更准确。
呼应总纲 P5 的"商业化:配额、计费、多环境"——配额已做、计费(计量层)已做、真正的商业化计费是下一步。不夸大,是因为面试或客户面前,说"我们有计费"和"我们有用量统计+硬配额"是两码事。
5. P5 的四点核心教训 + 刻意不做的事
P5 这一路的四句话,记牢:
- Agent 先"可控"再"智能"——白名单 + 护栏 + 单步默认,多步作为可开启增强,比默认多步稳。
- 多模态先做"文本化"——OCR + 表格结构化是性价比最高的两步,图片理解延后。
- 私有化讲清"交付到第几层"——L1 离线闭环能演示,L2/L3 另开规格,别拍胸脯。
- 计费别叫早——计量+硬配额是底座,套餐/支付/出账才是商业化,分清楚再对外说。
刻意不做的事(和 P4 的"WORM 哈希链"一样,能说清"为什么不做"比硬上更重要):
- 拖拽画布(总纲 6.6:成本是 Dify 的几万行,只读流程图替代,1/50 成本);
- 图片理解 / vision(L1 离线栈成本与可靠性未到承诺程度);
- 套餐 / 订阅 / 支付(超出"用量+硬配额"范围,属独立商业化项目);
- 跨库对比检索(RetrieveTool 解锁其它
kb_id,明确留作 5.1b 不合入必验)。
6. 收官:六阶段一路走来
从 P0 到 P5,这个知识库从一行 CLI 跑通 切分→向量化→检索→生成,长成了:
P0 地基 一行 CLI 跑通 RAG 闭环(37 个坑)
P1 MVP FastAPI + Next.js,能演示的知识库(SSE 流式问答)
P2 质量 混合检索 + rerank + Golden Set 评测(策略可切换对比)
P3 工程化 异步入库(ARQ) + KB级RBAC + 发布门禁 + 审计可观测
P4 企业能力 多租户 + SSO(OIDC/PKCE) + 审批流 + 连接器
P5 产品化 Agent(可控多步) + 多模态(OCR/表格) + 私有化(L1) + 计费(计量/配额)
作为前端转 AI 的人,这套路线给我的真正收获不是某个 API 怎么调,而是三件事:
- 垂直切片 + 螺旋升级是真的——每个阶段都交付一个能跑的增量,永远不会"两个月啥也没有"。
- 抽象是长出来的,不是设计出来的——编排层 P2 才建、Agent P5 才多步,早一步都是债。
- 诚实标注落地度——哪块真做了、哪块只有底座,比"全栈都行"经得起面试官和客户的追问。
如果你也正从前端往 AI 走,我的建议是:把这套当脚手架,按你自己的业务场景,用"用户故事 + 验收标准"从 P5 四块里挑一块先做。别贪全,先做透一块,比收藏十个教程强。
到这里,六篇系列正式完结。感谢一路看到这里的你——P0 的 37 个坑、P2 的评测、P4 的租户隔离、P5 的"可控多步",希望它们让你少走点弯路。有问题、有不同做法,欢迎评论区交流,我会回来回复。
(系列总目录见 总纲;逐篇链接已分别在各自开篇的"上一篇"处回填。)
如果你觉得本文还可以,那就点击一下推荐,让更多人看到吧!
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

浙公网安备 33010602011771号