LogiChat 是一个聊天机器人(chatbot)平台。客户上传文档,获取聊天组件(chat widget),无需接触模型。过去两年里,“训练机器人”意味着在仪表盘表单中手动整理问答列表——question 和 answer 对,一次一行,作为少样本(few-shot)对注入提示词(prompt)。我刚刚删除了这一切,并基于 Google 的 Agent 开发工具包 (ADK) 重建了整个流水线(pipeline),使用 Vertex AI 作为模型,Firestore 作为向量存储(vector storage),其余部分使用 Cloud Run。
这不是一个“hello world”的 agent 演示——这是我在过去几个月重建整个流水线后确定的架构,涉及三个 Cloud Run 服务(第四个,Stripe 充值令牌订阅器,未改动),以及一个最终被证明比其他任何设计选择都重要的决定:将检索(retrieval)作为模型前的步骤,而不是 ADK 的工具(tool)。
端到端的技术栈(stack)
下面的流水线涉及其中三个服务——apps/api、apps/subscribers/doc-processor 和 apps/agent——以及 Firestore 向量索引(vector index)。apps/api 在图中出现了两次,因为它既充当上传入口(ingress),又充当为 agent 生成 ID 令牌(ID token)的网关(gateway)。点击五个阶段,查看每个跳转点哪个服务处于活动状态,以及跨越每个边界的是什么负载(payload):
交互式图表: 逐步查看五阶段流水线——上传(Upload)→ 处理(Process)→ 检索(Retrieve)→ 接地(Ground)→ 回答(Answer)——请访问 dalenguyen.me 上的原始文章。每个跳转点显示哪个服务处于活动状态(仪表盘 →
apps/api→ Cloud Storage →doc-processor→ Firestore →apps/agent→ Vertex AI)以及跨越边界的是什么负载。
与教科书式的 RAG 技术栈相比,有两个不同之处:
apps/api,Express)是一个独立的服务,它生成 Cloud Run ID 令牌来调用 agent。agent 不对外公开访问。两者都是有意为之。两者也正是本文真正要讨论的内容。
为什么选择 ADK
我选择 Google 的 Agent 开发工具包 而不是 LangGraph、CrewAI 或手动编写的循环(loop),原因只有一个:agent 服务完全运行在 GCP 上,而 ADK 是这些工具中唯一一个从一开始就为 Vertex AI 构建的。 Python SDK 提供了 google.adk.agents.Agent 和 google.adk.runners.InMemoryRunner,它们通过 google-genai 进行路由,当 GOOGLE_GENAI_USE_VERTEXAI=TRUE 时,google-genai 已经能够与 Vertex AI 通信。没有 LangChain 适配器(adapter),没有提供商垫片(provider shim),在预发布(staging)和生产(prod)环境之间不会出现意外的模型名称漂移(model name drift)。
具体来说,我的 apps/agent 是一个 53 行的 pyproject.toml Python 3.12 服务:
[project]
name = "logichat-agent"
version = "0.1.0"
description = "LogiChat ADK agent service (Python) — Gemini 2.5 Flash via Vertex AI."
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"pydantic>=2.7",
"uvicorn>=0.30",
# google-adk 是 Agent 开发工具包 Python SDK。宽松地锁定版本
# 因为 ADK 的 API 仍在变动;后续问题将涉及
# 真正的 Runner(会话 / 工具 / 流式传输)。
"google-adk>=0.1.0",
# google-genai 随 ADK 一起提供,但我在 runner 适配器中
# 直接导入 google.genai.types,因此显式声明它。
"google-genai>=1.0",
# google-cloud-firestore 是我用于每个应用块
# 向量搜索的客户端。检索层需要它。
"google-cloud-firestore>=2.20",
]
五个运行时依赖(runtime dep)。FastAPI 用于 HTTP 接口,Pydantic 用于传输契约(wire contract),uvicorn 用于 Cloud Run 入口点(entrypoint),google-adk 用于 agent,google-genai 用于类型化消息部分(typed message parts),google-cloud-firestore 用于 KNN 搜索。ADC 适用于所有这些——任何地方都不需要 API 密钥。
Agent 服务,自上而下
apps/agent 分为四个文件,每个文件负责一项工作:
| 文件 | 职责 |
| --- | --- |
| src/agent/main.py | uvicorn 入口点;绑定 $PORT(Cloud Run 注入) |
| src/agent/agent_definition.py | 构建 ADK Agent + InMemoryRunner,封装在 RunnerProtocol 中 |
| src/agent/retrieval.py | 通过 Vertex AI 进行 Firestore find_nearest + text-embedding-005 |
| src/agent/api/app.py | FastAPI 工厂:POST /run,GET /healthz |
main.py 是六行编排代码(orchestration):
# apps/agent/src/agent/main.py
def main() -> None:
runner = build_agent() # 构建 ADK 支持的适配器
app = create_app(runner=runner) # 将 runner 接入 FastAPI
port = int(os.environ.get("PORT", "8080"))
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
build_agent() 是连接 ADK 的地方。以下是全部代码:
# apps/agent/src/agent/agent_definition.py
MODEL_ID = "gemini-2.5-flash"
SYSTEM_INSTRUCTION = (
"You are LogiChat's support assistant. Answer the user's question using "
"ONLY the context retrieved from the company's knowledge base. If the "
"retrieved context does not contain the answer, say you don't know and "
"suggest the user rephrase or contact a human. Never invent facts, "
"product names, or policies that are not present in the retrieved "
"context. Each retrieved block is prefixed with an internal "
"[doc:<id>] marker so you can tell sources apart while reasoning — "
"these markers are for your own use only and must never appear "
"anywhere in the text you show the user."
)
def build_agent() -> RunnerProtocol:
_require_env("GOOGLE_GENAI_USE_VERTEXAI") # TRUE → 通过 Vertex AI 路由
_require_env("GOOGLE_CLOUD_PROJECT") # logichat-dev / logichat-prod
_require_env("GOOGLE_CLOUD_LOCATION") # us-central1
# 内联导入:允许单元测试在未安装 google-adk 的情况下运行。
from google.adk.agents import Agent
from google.adk.runners import InMemoryRunner
root_agent = Agent(
name="logichat_faq_agent",
model=MODEL_ID,
instruction=SYSTEM_INSTRUCTION,
# tools=[firestore_retrieval_tool] ← 有意不作为 ADK 工具
)
runner = InMemoryRunner(agent=root_agent)
return _AdkRunnerAdapter(runner)
环境变量(env var)在启动时进行验证,因此配置错误的部署会在第一个请求时失败——而不是在第一次部署时。模型 ID 在代码中固定,因此意外的环境变量覆盖不会静默地切换到不同的模型系列。区域(region)和项目(project)仍然来自环境变量,因此预发布和生产环境可以在不重新构建的情况下进行区分。
`RunnerProtocol` 接缝(seam)
google.adk.runners.InMemoryRunner 暴露的接口比我的 HTTP 层需要的更多——会话(session)、事件流(event stream)、函数调用(function call)。我将其封装在一个协议(protocol)中,以便在上游 SDK 变动时,路由处理器(route handler)保持稳定:
# apps/agent/src/agent/api/dependencies.py
class RunnerProtocol(Protocol):
async def run(
self,
*,
app_id: str,
session_id: str,
question: str,
config: dict[str, Any],
) -> RunnerResult: ...
_AdkRunnerAdapter 实现了该协议,并将请求转发到真正的 ADK runner。HTTP 层只知道 RunnerProtocol——它从不导入 google.adk。这之所以重要,是因为:ADK Python SDK 的 API 仍在变动中。 封装它使我以后可以交换实现(会话、评估框架、流式传输),而无需触及 app.py。
适配器在调用 ADK Runner 之前 执行检索。这是值得理解的设计选择。
检索作为模型前的步骤,而不是 ADK 的工具(tool)
这是整个系统中最重要的架构决策,也是大多数 ADK 教程搞错的地方。
在 ADK 中,“显而易见”的模式是将检索暴露为一个工具(tool):
# 我没有做的事情。
firestore_retrieval_tool = FunctionTool(firestore_retrieval)
root_agent = Agent(
name="logichat_faq_agent",
model="gemini-2.5-flash",
instruction=...,
tools=[firestore_retrieval_tool], # 模型自行决定是否调用
)
我评估并拒绝了它。有两个原因:
defaultAnswer。ADK 工具在模型循环内部触发——当工具运行时,你已经支付了输入令牌(input-token)的成本。基于工具的设计无法强制执行“无有用上下文”的短路,因为模型必须首先决定调用该工具。app_id 绝不能成为模型控制的参数。将其暴露为工具输入会让精心构造的提示词(prompt)将检索转向另一个租户的集合(collection)。我在受信任的调用方(config / RunnerProtocol.run 的 app_id)一侧进行服务端检索范围限定,而不是从模型侧。因此,检索位于适配器中,在 ADK runner 被调用之前:
# apps/agent/src/agent/agent_definition.py
async def run(self, *, app_id, session_id, question, config):
# 1. 检索 — 模型前,不是 ADK 工具
chunks = await self._retrieve(app_id=app_id, question=question)
if not chunks:
# 没有有用上下文 → 短路到 defaultAnswer。
# 也涵盖了检索引发异常的情况:_retrieve 在失败时
# 返回空列表和一个标志。
return RunnerResult(answer=_default_answer(config), grounded=False, retrieval=[])
# 2. 组合接地后的用户消息
chunk_block = "
".join(_format_chunk(c) for c in chunks)
user_text = f"{chunk_block}
User question: {question.strip()}"
# 3. 针对接地后的消息运行 ADK 流程
runner = self._adk_runner
user_id = app_id # 每个 LogiChat 应用对应一个 ADK 用户
session = await runner.session_service.get_session(
app_name=runner.app_name, user_id=user_id, session_id=session_id
)
if session is None:
await runner.session_service.create_session(
app_name=runner.app_name, user_id=user_id, session_id=session_id
)
message = types.Content(role="user", parts=[types.Part(text=user_text)])
final_text = ""
async for event in runner.run_async(
user_id=user_id, session_id=session_id, new_message=message
):
if event.is_final_response() and event.content and event.content.parts:
final_text = "".join(part.text or "" for part in event.content.parts)
return RunnerResult(
answer=sanitize_answer(final_text),
grounded=True,
retrieval=[
RetrievalHit(
chunk_id=c.chunk_id, document_id=c.document_id, distance=c.distance
)
for c in chunks
],
)
有三点值得指出:
asyncio.to_thread 封装了同步的 google-cloud-firestore 和 google-genai 调用,这样缓慢的 Firestore 往返(round-trip)不会阻塞 uvicorn 事件循环。ADK 本身是异步原生的(async-native);检索层是请求路径中唯一的同步部分。_retrieve 吞掉异常,记录警告,返回 []。请求仍然通过非接地的 ADK 流程成功。短暂的 Firestore 中断会降低答案质量;但不会导致服务宕机。RunnerResult 除了 answer 之外,还携带 grounded + retrieval。网关将检索元数据记录在会话文档(conversation doc)上,以便我可以离线评估检索质量。原始的 ADK Runner 契约只返回文本;我通过适配器对其进行了扩展。检索层
apps/agent/src/agent/retrieval.py 故意保持小巧——它拥有适配器需要的两个操作:嵌入问题,拉取最相关的块。agent 服务中的其他任何部分都不会直接接触 Firestore 或嵌入 API。
EMBEDDING_MODEL = "text-embedding-005"
EMBEDDING_DIMENSION = 768
DEFAULT_DISTANCE_THRESHOLD = 0.5 # COSINE: 0 = 相同,2 = 相反
DEFAULT_TOP_K = 5
KNN 搜索使用 COSINE 距离:
# 注意:这两个符号都没有从包根目录重新导出。两者
# 都会针对真正的 google-cloud-firestore 2.x 安装引发 ImportError。
# 不要“修复”为包根目录——它会在每个请求上静默地
# 回退到非接地后备方案。
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
from google.cloud.firestore_v1.vector import Vector
query = db.collection(f"logichat-apps/{app_id}/chunks").find_nearest(
vector_field="embedding",
query_vector=Vector(query_vector),
distance_measure=DistanceMeasure.COSINE,
limit=limit,
distance_result_field="vector_distance",
)
阈值在 KNN 返回后在 Python 中应用。原因:find_nearest 按距离返回最近的 limit 个块;阈值是一个独立的问题(召回率 vs 精确度)。将它们混在查询中意味着每个应用所有者都必须知道正确的 top-k 来平衡两者。最好让参数保持独立。
写入路径(write path)和读取路径(read path)必须在维度上保持一致。 firestore.indexes.json 中的 KNN 索引是为 768 维构建的,平面索引(flat index):
{
"collectionGroup": "chunks",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "embedding",
"vectorConfig": {
"dimension": 768,
"flat": {}
}
}
]
}
这里的漂移是静默且灾难性的——一个使用 1536 维模型嵌入的块,存储到 768 维索引中,要么写入失败,要么更糟,产生看起来合理但实际无意义的结果。我将 EMBEDDING_MODEL 和 EMBEDDING_DIMENSION 固定为模块常量,并在 API 返回错误形状时大声失败:
if len(values) != EMBEDDING_DIMENSION:
raise RuntimeError(
f"embedding model {EMBEDDING_MODEL} returned {len(values)} dims, "
f"expected {EMBEDDING_DIMENSION}"
)
文档处理器(doc-processor)——块的来源
agent 的工作是回答问题。文档处理器的工作是确保有块可以从中回答。
apps/subscribers/doc-processor 是一个 Cloud Run 服务,订阅了项目存储桶上的 google.cloud.storage.object.v1.finalized 事件。触发器过滤器是路径前缀 apps/{appId}/docs/{documentId}/{filename}——该前缀之外的任何内容都会被静默忽略。为什么基于 Storage 事件而不是 Firestore 写入来驱动?因为 API 在返回签名 URL(signed URL)之前 写入 pending 文档,因此元数据可能比实际上传延迟几秒钟。基于 Storage 事件驱动意味着我等待真正的字节。
当事件触发时:
// apps/subscribers/doc-processor/src/app/doc-processor.handler.ts
const [buffer] = await storage.bucket(event.bucket).file(event.name).download()
const parsedDoc = await parseDocument(buffer, mimeType)
const chunks = chunkText(parsedDoc.text)
const vectors = await embedBatch(chunks)
await persistChunks(appId, documentId, uid, chunks, vectors, embeddingModel)
await markReady(appId, documentId, chunks.length, embeddingModel)
其中三行隐藏了真正的选择。
分块器(Chunker)(libs/server/ai/src/lib/chunker.utils.ts)。 800 令牌的块,100 令牌重叠。首先在段落边界处分割,当段落溢出时回退到句子边界,并通过将前一个块的尾部附加到下一个块来实现重叠。*.utils.ts 中的纯函数——没有注入的依赖项,没有生命周期,这是仓库的约定。
解析器(Parser)。 纯文本、markdown、HTML 和 PDF。HTML 解析器使用 cheerio,并在提取文本之前去除 、、、、、——这是我实际发布并捕获的一个 bug,当时一个客户的“关于”页面导航最终主导了他们的检索索引。
嵌入(Embeddings)。 通过 Vertex AI 使用 text-embedding-005,以 50 个为一组进行批处理,并在遇到 429/5xx 错误时重试两次。一个 50 页的 PDF 可能产生 200 多个块;我不希望每个块都进行一次 HTTP 调用。
向量存储(Vector storage)。 每个块进入 logichat-apps/{appId}/chunks/{chunkId},embedding 存储为 FieldValue.vector(...)——而不是普通的 number[]。Firestore 的 KNN 索引只能查询存储为 Vector 值的字段;普通的数字数组对向量搜索是不可见的,并且每次查询都会得到零个结果,且没有任何错误:
// FieldValue.vector 是 KNN 索引能看到此字段的唯一方式。
embedding: FieldValue.vector(vectors[j]),
我仅在块写入时使用现代的 @google-cloud/firestore v7 客户端,因为 firebase-admin@^9 捆绑了 v4,而 v4 没有 FieldValue.vector。其他所有内容都继续使用 firebase-admin——它们共享 ADC,因此可以工作,但类型定义存在冲突,而构建/源代码的深层导入(deep import)避免了这个问题。
提示词(prompt):带有 `[doc:]` 标记的接地块
每个检索到的块都带有一个内部的 [doc: 前缀标记:
def _format_chunk(chunk: RetrievedChunk) -> str:
return f"[doc:{chunk.document_id}]
{chunk.content}"
用户消息变为:
[doc:abc123]
Our return policy allows returns within 30 days of purchase...
[doc:def456]
Shipping is free for orders over $50...
User question: how long do I have to return a dress?
该标记允许模型在其推理过程中通过 ID 引用,但该标记绝不能出现在面向用户的答案中。我在系统提示词(system prompt)中告诉模型这一点:
每个检索到的块都带有一个内部的
[doc:标记前缀……这些标记仅供您自己使用,绝不能出现在您向用户展示的文本中的任何位置。]
然后我无论如何都会在代码中去除它们。当提示词规则是唯一阻止内部令牌出现在面向客户文本中的东西时,双重保险(belt-and-braces)是正确的做法——曾经有一个真实事件,标记泄露到了 FAQ 组件(widget)中。清理器(sanitiser)只有几行:
_DOC_MARKER_RE = re.compile(r"s*[doc:[^]]*]s*")
_SPACE_BEFORE_PUNCT_RE = re.compile(r" ([.,;:!?])")
def sanitize_answer(text: str) -> str:
cleaned = _DOC_MARKER_RE.sub(" ", text)
cleaned = re.sub(r" {2,}", " ", cleaned)
cleaned = _SPACE_BEFORE_PUNCT_RE.sub(r"1", cleaned)
return cleaned.strip()
第二个正则表达式是微妙之处——移除一个紧挨在句点之前的标记会留下一个空格,这看起来比泄露本身更糟糕。始终检查“移除后留下与标点相邻的空格”模式。
Cloud Run + 服务间 ID 令牌
网关和 agent 是两个 Cloud Run 服务。网关使用 Google 签名的 ID 令牌调用 agent:
// libs/server/ai/src/lib/agent.service.ts
const { agentUrl } = getAgentConfig()
const client = isPlainHttpUrl(agentUrl)
? plainHttpClient
: await authFactory().getIdTokenClient(agentUrl)
const response = await client.request({
url: `${agentUrl}/run`,
method: 'POST',
data: { appId, sessionId, question, config },
})
agent 服务部署时带有 --no-allow-unauthenticated,因此访问它的唯一方式是使用由同一项目中的服务帐户(service account)生成的有效 ID 令牌。google-auth-library 的 GoogleAuth#getIdTokenClient(agentUrl) 处理令牌刷新,并在每个请求上附加 Authorization: Bearer ...,因此我不需要自己管理令牌。
部署目标:
gcloud run deploy logichat-agent-run
--image us-central1-docker.pkg.dev/${PROJECT_ID}/logichat/agent
--region us-central1
--platform managed
--no-allow-unauthenticated
--port 8080
--service-account ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com
--update-env-vars
GOOGLE_GENAI_USE_VERTEXAI=TRUE,
GOOGLE_CLOUD_PROJECT=${PROJECT_ID},
GOOGLE_CLOUD_LOCATION=us-central1,
VERTEX_CHAT_MODEL=gemini-2.5-flash
一个 Cloud Run 服务,一个服务帐户(默认的 Compute Engine SA),三个通过 Vertex AI 路由的环境变量。agent 服务帐户需要在项目上拥有 roles/aiplatform.user 角色——同一个绑定涵盖了聊天和嵌入,不需要第二个角色。
我保留的后备方案
我删除了读取 examples 的提示词构建器(prompt-builder)。用于管理问答示例的仪表盘页面已经消失。新的上传/列表/删除对话框取代了它们。
但是集合(collection)仍然存在。 每个现有客户都有他们两年多来编写的 examples 文档。删除这些文档会比问答流程本身带来更差的客户体验。所以后备方案是:
# 当文档检索为空时...
chunks = await self._retrieve(app_id=app_id, question=question)
if not chunks:
# ...在放弃之前尝试使用旧的问答示例。
examples = await self._retrieve_examples(app_id=app_id)
if examples:
return self._answer_with_examples(examples, question, config)
return RunnerResult(answer=_default_answer(config), grounded=False, retrieval=[])
# 否则:来自文档的接地答案
...
retrieve_examples 是一个轻量封装:
@dataclass(frozen=True)
class Example:
question: str
answer: str
DEFAULT_EXAMPLES_LIMIT = 20
def retrieve_examples(db, app_id, *, limit=DEFAULT_EXAMPLES_LIMIT) -> list[Example]:
"""为 `app_id` 获取最多 `limit` 个旧的问答对。
无排序,无评分——这些是手工整理的小型集合,不是
排序后的结果。
"""
...
上限 20 很重要。旧的提示词构建器没有上限;一个有 200 个示例的客户已经在延迟方面为此付出了代价。新的提示词块看起来像:
Q: How do I reset my password?
A: Click "Forgot password" on the login page.
Q: Do you ship internationally?
A: Yes, we ship to 40+ countries. See our shipping page for details.
User question: can I return a dress after 30 days?
文档 RAG 仍然是主要路径。示例是后备方案,仅在检索未返回任何内容时运行——没有有用的块,集合为空,或者检索本身出错。上传了文档的客户会得到文档 RAG 答案(更好的召回率,与文档一样新鲜)。尚未迁移的客户仍然可以从他们手工整理的示例中获得答案。两者都没有的客户会得到 defaultAnswer。
我会做哪些不同的事情
有几件事我第一次做错了,如果从头重建,我会改变。
firebase-admin v4 / 现代 firestore v7 的拆分很丑陋。 将 firebase-admin 升级到 v13 是正确的修复方法;这是一个单独的 PR 工作量的类型变动。FieldValue.vector 的变通方法有效,但它使文档处理器的代码看起来比应有的更奇怪。
find_nearest 符号位置。 google.cloud.firestore.Vector 和 firestore_v1.DistanceMeasure 没有从包根目录重新导出。此模块的前两个发布版本都在每个请求上静默降级到非接地后备方案,因为导入引发了 ImportError,而 except 子句将其吞掉了。内联注释以警告未来的读者——这并不明显。
阈值和 top-k 应该由数据驱动,而不是环境变量驱动。 RETRIEVAL_DISTANCE_THRESHOLD 对于 v1 来说没问题。下一步是从仪表盘进行每个应用的调优——不同的客户有不同的“有用”阈值,0.5 是一个合理的默认值,但不是所有人的正确答案。KNN top-k 应该属于同一个配置。
Agent 和网关应该共享一个 runner。 目前 apps/api 通过 Cloud Run 服务间 ID 令牌向 apps/agent 发送 POST 请求。这个往返对用户是不可见的,但增加了大约 30–80 毫秒的延迟,以及一个可能失败的点。长期的解决方案是将 ADK 流程内联到 API 服务中以处理简单情况,或者将两者作为一个 Cloud Run 服务运行,并带有内部路由。我测量过,决定目前分离带来的延迟是值得的(独立部署,更简单的认证),并将统一工作搁置了。
数据
旧的包含 100 个示例的提示词每个请求需要 12–18K 令牌,p50 延迟约 1.2 秒,按 GPT-4o-mini 定价计算每个请求约 $0.003。新的文档 RAG 提示词需要 1.5–3K 令牌(系统提示词 + 3–5 个检索到的块 + 问题),p50 延迟约 450 毫秒,每个请求约 $0.0006。在客户自己的文档上的召回率显著提高,因为机器人现在搜索他们的实际内容,而不是转述的问答。
这些数字是方向性的,不是受控的基准测试。 我没有运行一对一的 A/B 测试,因为这次迁移同时改变了三件事:训练数据形状(问答对 → 检索到的块)、模型(GPT-4o-mini → Gemini 2.5 Flash)和提供商(OpenAI → Vertex AI)。下面每个请求 4–5 倍的成本差异主要由模型/提供商的改变主导,而不是由检索的改变主导——不要将其理解为“RAG 比少样本便宜 4–5 倍。”所有数字都是每个请求的推理成本;文档摄取(上传时的一次性嵌入)被排除在外。关于召回率的定性声明是我有信心的一点:在客户实际文档上的文档 RAG 每次都胜过在转述摘要上的少样本问答,这也是我最初这样做的原因。
全套更改涵盖了文档流水线、agent 服务、检索层、仪表盘 UI 改造,以及问答后备方案的单独设计过程。
给今天在 ADK 上构建的人的 TL;DR
app_id 暴露给模型控制的参数。build_agent() 应该在配置错误时快速失败。InMemoryRunner 封装在你自己的协议中。 ADK 的接口比你的 HTTP 层需要的更大,而且 SDK 仍在变动。FieldValue.vector 存储 Firestore 嵌入。普通数组对 KNN 是不可见的。EMBEDDING_DIMENSION 上保持一致。asyncio.to_thread 中运行同步的 Firestore / genai 调用。 不要让缓慢的往返阻塞 uvicorn 事件循环。