第四十八章 AI 集成(内部模块精读)
篇别:第十六篇 AI 工程集成
本章学习目标
- 掌握本仓库
ai模块 的 目录拓扑、模型依赖、定时任务策略 与 ACL 边界。 - 能复述 RAG 全链路:
ai.agent.source→ 附件分块 →ai.embedding+ pgvector →_get_similar_chunks→_build_rag_context。 - 区分 AI 问答 中 WebSocket 的 三类角色(模型 token 流、信令、Bus 新消息)与 SSE 的 单向流式;对照 内置 Discuss AI(RPC +
message_post+ Bus)与 自研打字机(SSE/WS);掌握 语音 下 浏览器 Realtime WebSocket 与 文本问答 的差异。 - 理解
LLMApiService.request_llm的 工具调用循环、系统参数限流 与PREPROMPTS/_ai_serialize_fields_data对 提示词行为 的约束。 - 能编写 数据分析向 Prompt,并与 第四十一章 安全原则一致。
导读:与第四十一章、源码的关系
第四十一章 讲 产品能力与安全观;本章 按目录读代码,便于 二开、排障与 Code Review。正文默认源码根为:
appstore/odoo_blog/odoo19_devbook/ai/
说明:企业版完整栈中,ir.actions.server 的 AI 状态 等可能依赖 ai_fields 等附加模块;本教材副本中 ir_actions_server.py 仍保留对 ai_fields 的 import,若你只在独立仓库看 ai,需以 实际可安装依赖 为准。用户向功能说明见 AI(User Docs)。
48.0 ai 模块拓扑速览
48.0.1 目录职责表
| 路径 | 职责 |
|---|---|
models/ |
业务模型与继承:ai.agent、ai.agent.source、ai.embedding、ai.topic、ai.composer、discuss.channel / mail_* 补丁、ir.actions.server(AI 动作)、ir.attachment(分块/抽取)、base(_ai_* 上下文序列化)等。 |
controllers/ |
main.py:/ai/generate_response 等 Discuss 入口;agent.py:/ai/transcription/session 语音会话票据。 |
utils/ |
llm_api_service.py:HTTP 调用、request_llm 工具循环;llm_providers.py:OpenAI / Google / Qwen 与嵌入模型映射;html_extractor.py:URL 正文抽取;ai_logging.py:调用日志会话。 |
data/ |
Cron(见下)、ai_topic_data.xml、ai_agent_data.xml、ai_composer_data.xml 等演示与默认数据。 |
static/src/ |
后端 Web 资源:Discuss 补丁、ask_ai_button、vad_audio_recorder.js、HTML 编辑器插件、agent_add_source_dialog 等。 |
orm/field_vector.py |
Vector 字段类型及 向量索引 声明(与 PostgreSQL + pgvector 配合)。 |
security/ir.model.access.csv |
ai.agent / ai.embedding / ai.topic 等 的 普通用户只读、系统用户全权 等规则。 |
tests/ |
工具调用、Gemini 集成、Discuss 频道、权限等 单元/集成测试(阅读测试可快速理解契约)。 |
48.0.2 依赖与资产
__manifest__.py:depends: ['mail'];向web.assets_backend、mail.assets_public、im_livechat.assets_embed_core等 注入补丁,使 Discuss / Livechat / Portal 均能触达 AI 入口。- 许可
OEEL-1:与 企业版 分发一致;教学副本 勿当作社区版默认可用能力 对外承诺。
48.0.3 本节练习
- 简答:为何
ai必须依赖mail才能形成闭环? - 实操:在源码中 全文搜索
_trigger(),列出 与嵌入/抓源 相关的 cron xmlid。
参考答案提示:1. 回复落点 在 message_post / Discuss 线程,无 mail 则 无标准交互面。
48.1 内部接入方式(模型、Composer、频道)
48.1.1 核心模型分工
| 模型 | 要点 |
|---|---|
ai.agent |
LLM 模型、系统提示、topic_ids(ai.topic)、sources_ids、response_style → temperature;编排 _generate_response(系统消息、RAG、LLMApiService.request_llm、工具)。 |
ai.topic |
主题说明 instructions + tool_ids(ir.actions.server 且 use_in_ai);源码 TODO 注明未来可能 与 AI 型 server action 合并。 |
ai.agent.source |
binary / url 源、status / is_active / error_details;创建时 绑定附件 并 _trigger 对应 cron(见上一版章内代码引用)。 |
ai.embedding |
分块文本 + embedding_vector;余弦距离检索;_cron_generate_embedding 批量补向量并 回写 source 状态。 |
ai.composer |
界面入口 → Agent 映射:interface_key(如 systray_ai_button、chatter_ai_button、mail_composer)+ focused_models + default_prompt + available_prompts。 |
48.1.2 Discuss 频道:ai_chat 与上下文 JSON
discuss.channel 扩展 频道类型 ai_chat,并增加 ai_env_context(JSON)与 ai_agent_id:
ai_agent_id使用groups=fields.NO_ACCESS:防止成员篡改 代理绑定导致 安全问题(注释写明 garbage collection 等风险)。create_ai_draft_channel:按interface_key+ 可选focused_models选ai.composer,再ai_agent._create_ai_chat_channel;把default_prompt+record._ai_initialise_context(...)写入ai_env_context,供后续对话使用。
48.1.3 定时任务:仅 _trigger,不设高频 interval
data/ir_cron.xml 中两条 cron 的 interval_number=9999、interval_type=months:几乎不会按时间自跑;实际由业务代码 _trigger() 触发(embedding、URL 处理)。升级或排查时 勿误以为「cron 坏了」——要查 是否被 trigger、worker 是否处理队列。
48.1.4 访问控制(节选)
普通用户对 ai.agent / ai.topic / ai.embedding / ai.composer / ai.prompt.button / ai.agent.source 多为 只读或无写权限;增删改 多在 base.group_system。对接 Portal / Livechat 时,仍需记录规则与消息 ACL 防止 跨租户读向量或附件(详见 security/ir.model.access.csv)。
48.1.5 截图占位

48.1.6 本节练习
- 简答:
ai.composer的interface_key解决什么 路由问题? - 判断:Cron 表里 interval 很大 表示 嵌入永远不会执行。( )
参考答案提示:2. 错;_trigger() 可 即时排队。
48.2 RAG 与向量层(ai.embedding 精读)
48.2.1 字段与索引
embedding_vector = Vector(size=1536)与_embedding_vector_idx(USING ivfflat (embedding_vector vector_cosine_ops)):依赖 pgvector;距离算子<=>在_get_similar_chunks中以1 - (embedding_vector <=> query)转为 相似度。checksum关联ir.attachment:同一文件多 source 可复用向量(与create_from_attachments思路一致)。
48.2.2 相似块检索(SQL 核心)
return self.browse(id_ for id_, *_ in self.env.execute_query(SQL(
'''
SELECT
ai_embedding.id,
1 - (embedding_vector <=> %s::vector) AS similarity
FROM ai_embedding
INNER JOIN ir_attachment ON ir_attachment.id = ai_embedding.attachment_id
WHERE ir_attachment.checksum = ANY(%s) AND ai_embedding.embedding_model = %s
ORDER BY similarity DESC
LIMIT %s;
''',
query_embedding, target_checksums, embedding_model, top_n)
)
)
解读:仅在 当前 Agent 活动 source 对应的 checksum 集合 内检索,避免 全库扫描;embedding_model 必须一致(换模型需 重新嵌入,见 _sync_new_agent_provider 一类逻辑)。
48.2.3 _cron_generate_embedding 两阶段
- 建块:对
processing且有附件 的 source,若 (checksum, model) 尚无块,则attachment_id._get_attachment_content()→_setup_attachment_chunks。 - 补向量:查找
embedding_vector为空 的记录,批量调用get_embedding,失败打has_embedding_generation_failed;配合ir.cron._commit_progress可中断的长任务。最后source._update_source_status。
48.2.4 自动清理
_gc_embeddings:Autovacuum 删除 已无任何 Agent source 引用 的 chunk,防止 孤儿向量 占空间。
48.2.5 本节练习
- 简答:为何检索必须带
embedding_model条件? - 实操:画 从
source.create到indexed的 状态机(processing / failed / indexed)。
参考答案提示:1. 不同模型向量空间不可比,混用会导致 语义检索无效。
48.3 问答交互:JSON-RPC、Bus、WebSocket 与 SSE
48.3.1 Discuss 路径:一次 RPC,不是「模型流」协议
控制器 AIController.generate_response(auth="public" + add_guest_to_context,供 Livechat 访客):
@http.route(["/ai/generate_response"], type="jsonrpc", auth="public")
@add_guest_to_context
def generate_response(self, mail_message_id, channel_id, current_view_info=None, ai_session_identifier=None):
channel = self._get_ai_channel_from_id(channel_id)
if not channel:
raise NotFound()
message = self._get_message_with_access(mail_message_id)
if message:
channel.sudo().ai_agent_id.with_context(current_view_info=current_view_info, ai_session_identifier=ai_session_identifier)._generate_response_for_channel(message, channel)
链路:
- JSON-RPC 一次请求触发生成;HTTP 在该次调用完成后 释放,本路由不承载 SSE/WS 的 token 流。
_generate_response_for_channel→LLMApiService.request_llm(服务端 阻塞至 得到完整回复或工具循环结束)→_post_ai_response→message_post。- 浏览器 「像聊天一样更新」:依赖 Discuss / mail 实时通道(常见底层为 WebSocket Bus 或 长轮询)——推送的是 新
mail.message事件,不是 提供商返回的delta逐字流。
同类路由:/ai/post_error_message、/ai/close_ai_chat(关闭 ai_chat 频道时可能 unlink)。
48.3.2 WebSocket 在 AI 问答中的用法(通用 + Odoo 对照)
WebSocket(WS) 为 全双工 长连接,在 AI 问答 类产品里常见 三类角色:
| 角色 | 典型用途 | 载荷形态 |
|---|---|---|
| A. 模型输出流 | 浏览器 ↔ BFF/网关;把 LLM 流式响应 切成 WS 帧 推给前端 | token / chunk、结束标记、错误 |
| B. 信令与控制 | 停止生成、重试、换模型、附带上下文 ID | JSON 指令与 ACK |
| C. 应用消息总线 | 聊天 UI 只认 业务消息(人/机器人各一条气泡) | 消息 ID、频道、body |
与内置 ai 模块的对应:
- C 类:AI 回复写入
message_post后,Discuss 通过 Bus(多为 WS) 让客户端 收到新消息 —— 属于 「消息级实时」,不是 A 类的 token 流。 - A/B 类:标准
ai模块未提供「Odoo 对浏览器持续推模型 delta」的 专用 WS 路由;自研时可考虑 Odoo HTTP WebSocket API(随版本而异) 或 独立 BFF,并处理 鉴权、多 worker、CSRF、超时。 - 语音:见 48.3.5,为 浏览器 ↔ 提供商 Realtime 的 WS,与 文本问答 区分理解。
运维:WS 需 心跳/重连;nginx 配置 Upgrade、Connection 与 proxy_read_timeout;多 worker 时常需 集中式 bus 协调订阅关系。
48.3.3 SSE 在 AI 问答中的用法(通用 + Odoo 对照)
SSE(Server-Sent Events) 使用 常规 HTTP:响应头 Content-Type: text/event-stream,正文按 data: …\n\n 分帧;前端可用 EventSource(多为 GET)或 fetch + ReadableStream(POST 开流 时常用后者)。方向:仅服务端 → 浏览器。
典型场景:
- 流式补全:上游 API
stream: true时,Controller 边读上游边 flush 为 SSE,前端 拼接 实现 打字机。 - 进度事件:RAG 检索中 / 工具执行中 发
event: status,与 最终答案 分离。 - 单向推送:用户 很少回传控制指令 时,SSE 比 WS 更轻;若需 频繁双向(打断、语音二进制),优先 WS。
与内置 ai 模块:Discuss AI 默认无 SSE 字流;自研 SSE 时注意 nginx proxy_buffering off(否则 chunk 被攒批、流式失效)、worker 超时、长连接期间 ORM 事务不要过大、输出须消毒(XSS)。
SSE 与 WS(仅论「推文字」):SSE 走 HTTP、穿透防火墙通常更容易;双向 与 二进制 选 WS。
48.3.4 三种机制在 AI 问答中的对比表
| 机制 | 方向 | 常见载荷 | 内置 Discuss AI | 自研「打字机」 |
|---|---|---|---|---|
| JSON-RPC | 请求-响应 | 调用参数/空返回 | 触发 _generate_response_for_channel |
可 投递 job 再推送 |
| WebSocket(Bus) | 多向 | 新邮件消息 | 收到 AI 气泡(整段或分条 message) | 可 兼推状态 |
| WebSocket(直连 LLM) | 双向 | 音频 / Realtime | 语音转写(48.3.5) | 多 不经 Odoo |
| SSE | 服务端 → 客户端 | token / 进度 | 未使用 | HTTP 侧流式首选 |
一句话:内置路径 = RPC 触发 + 服务端生成 + message_post +(常为 WS 的)新消息推送;SSE 或 WS 传模型字流 = 增强架构,需 单独设计。
48.3.5 语音:Odoo 换票 + 浏览器 WebSocket → OpenAI Realtime
AgentController:POST /ai/transcription/session(type="jsonrpc")返回 短期 session。VADAudioRecorder:
async setupTranscriptionSession(sessionInfo) {
if (!VADAudioRecorder.socket) {
VADAudioRecorder.socket = new WebSocket("wss://api.openai.com/v1/realtime", [
"realtime",
// Auth
"openai-insecure-api-key." + sessionInfo.value,
]);
要点:音频 不经 Odoo 中继;这是 本模块中明确的 WebSocket 用法(实时语音),与 48.3.2 所述 文本 token 流 区分。合规与 DPA 需覆盖 第三方实时通道。
48.3.6 本节练习
- 简答:
auth="public"的 AI 路由 为何仍可能安全?(提示:消息与频道校验) - 判断:Discuss 里 AI 回复 逐字出现 一定表示 使用了 SSE。( )
- 简答:SSE 流式场景下,nginx 开启响应缓冲 会导致什么 用户体验问题?
- 简答:Bus WebSocket 推送新消息 与 SSE 推送 token 能否 无设计混用?给出 一种 清晰架构。
参考答案提示:2. 错;多为 动画或多条 message,不等于 SSE。3. 延迟批量输出、像一次性出字 甚至 超时。4. 不宜混用无状态机;可 SSE 仅草稿流、最终以 message_post 定稿,或 单一通道。
48.4 LLM 服务层与工具循环
48.4.1 提供商与模型
utils/llm_providers.py 中 PROVIDERS:OpenAI / Google / Qwen 及其 LLM 列表 与 默认 embedding 模型;get_provider / get_provider_for_embedding_model 在 模型与 HTTP 客户端 间路由。
48.4.2 request_llm:多轮工具直到结束
request_llm 外层 _request_llm_silent:
- 读取
ir.config_parameter:ai.max_successive_calls(默认 20)、ai.max_tool_calls_per_call(默认 20)。 - 每一轮
_request_llm_*若返回 tool_calls,则 按名派发 到 Python 可调用;未知工具 写错误回包 让模型重试;超限 则 注入错误消息。 - 支持
__end_message:带 schema 的工具 可 提前终止循环 并附加 结语。
AI_MAX_SUCCESSIVE_CALLS = int(self.env["ir.config_parameter"].sudo()
.get_param("ai.max_successive_calls", "20"))
AI_MAX_TOOL_CALLS_PER_CALL = int(self.env["ir.config_parameter"].sudo()
.get_param("ai.max_tool_calls_per_call", "20"))
运维提示:生产环境若 复杂 Agent + 多工具,需 监控调用次数 与 日志(ai_logging),避免 成本与延迟失控。
48.4.3 ir.actions.server 的 AI 工具形态(概要)
state扩展ai:AI Action 带ai_action_prompt、子ai_tool_ids。use_in_ai/ai_tool_schema:把 server action 暴露为 LLM 可调工具(JSON Schema 校验参数,与utils/tools_schema配合)。- 警告:若工具链上 某 action 带
group_ids,界面会ai_tool_show_warning——工具执行仍可能跳过部分访问检查,须与第四十一章「工具层不得 sudo 扩大权限」一并设计。
48.4.4 本节练习
- 简答:
ai.max_tool_calls_per_call限制的是 什么粒度 的调用? - 实操:在测试库 调低
ai.max_successive_calls为 2,观察 复杂提问 的行为变化。
参考答案提示:1. 单次 LLM 返回中 并行 tool_calls 处理条数上限。
48.5 系统提示与记录上下文(PREPROMPTS 与 _ai_*)
48.5.1 PREPROMPTS 字典(可继承覆盖)
ai_agent.py 顶部 PREPROMPTS(节选语义):
tools:仅当 用户明确 或 最合适 时使用工具;日期 可用「今天」推算。restrict_to_sources:除问候外 必须 仅用上下文与历史;禁止编造;无上下文时 固定句式 提示未提供源。context/unaccessible_references:引用段落 与 不可见源 的处理规则。
restrict_to_sources 在 Agent 勾选 restrict_to_sources 时追加到系统消息,与 48.6 数据分析 Prompt 的 「不得臆造数字」 一致。
48.5.2 记录 JSON:_ai_serialize_fields_data**
models/models.py 对 base 继承:
AccessError字段直接跳过——不进入 AI 上下文。many2many/one2many超过 50 条 则 省略该字段,防 提示词爆炸。binary跳过;name/display_name类字段截断 减轻 注入面。mail_composer场景下把 整记录 JSON 塞进 初始 context。
48.5.3 URL 抓取:HTMLExtractor
utils/html_extractor.py:排除 script/style/nav/footer 等 XPath,降低噪声;失败时 ai.agent.source 写 failed + error_details。与 create_from_urls 仅管理员 组合,控制 SSRF 风险面。
48.5.4 本节练习
- 简答:
_ai_serialize_fields_data跳过 relational 超长列表 对 分析类提问 有何副作用? - 判断:
PREPROMPTS可 完全替代 记录规则。( )
参考答案提示:2. 错;ACL/规则 才是 硬隔离。
48.6 数据分析常用 Prompt(与模块行为对齐)
以下模板与 restrict_to_sources / 工具调用策略 兼容:若 无连接 BI 工具,应要求模型 只解释已给数字,禁止编造。
48.6.1 自然语言 → Domain(字段白名单)
系统:仅允许字段 [...];输出 JSON {"domain":[...],"explain":"..."};不得 使用未列出字段。
用户:「上月已交货未开票的销售订单,单笔金额 > X。」
48.6.2 read_group / Pivot 摘要
系统:以下仅为 聚合结果,无明细;用 三段:结论、异常、建议(建议须 可映射到 Odoo 菜单动作)。
用户:粘贴 分组键 + 度量。
48.6.3 同比 / 环比
系统:仅使用提供的 两期数字;缺一期则 回答无法比较。
用户:给出 本期/上期 销售额与订单数。
48.6.4 数据质量
系统:列出 疑似重复(键:partner_id + amount + date)与 建议核对步骤;禁止 建议 unlink()。
48.6.5 结合 RAG 的政策解读
系统:仅使用 ##Context information;若源未覆盖 渠道商 A,回答 不知道 并列出 缺失信息。
用户:「渠道商 A 是否适用附件中的返点条款?」
48.6.6 截图占位

48.6.7 本节练习
- 实操:为
sale.order写 10 字段白名单 + 禁止account.move。 - 简答:为何 48.6.2 要求 「建议映射到菜单动作」?
参考答案提示:2. 促使输出 可执行、可审计,减少 空话。
48.7 前端资源与扩展点(导读级)
static/src/discuss/*_patch.js:Discuss composer、thread、message 与 AI 的衔接。ask_ai_button.js/ai_chat_launcher_service.js:顶栏/快捷 Ask AI。components/agent_add_source_dialog:添加 Source 的客户端动作ai_open_sources_dialog(与ai.agent.source.action_open_sources_dialog呼应)。- HTML 编辑器:
prompt_plugin、voice transcription 等 嵌入式组件。
深入:按 第十四章 资产与 第三十二章 OWL 注册表 单步调试 加载顺序。
本章综合练习
- 组件图:从 用户发消息 到 频道出现 AI 回复,标出 RPC、模型方法、
request_llm、message_post、前端更新。 - 对比:SSE 流式 vs
message_post整段 —— 运维、UX、安全 各 两条。 - 源码题:说明
_get_similar_chunks中checksum = ANY(%s)的 安全/性能意义。 - 综合:若把
ai.max_successive_calls调到 100,列出 两条风险(成本/延迟/锁 任选角度)。
本章配套代码:ai/(见本教材目录)。与 第四十一章、第三十六章、第十四章 交叉阅读。下一章 第四十九章 Odoo 环境部署 介绍 Ubuntu / Windows / macOS 源码环境搭建。