2026年5月28日/ AI Application Notes

业务系统接入 LLM:提示词、结构化返回和会话如何落地

不把大模型当聊天框。以知识库问答和运维诊断为例,拆开接口调用、提示词组成、结构化结果、会话保存和可审计记录。

AILLM APIPrompt DesignStructured OutputSpring AI

以前写提示词文章,我容易停在“怎么提问更清楚”。这对日常使用 AI 有帮助,但放到业务系统里不够。

业务里真正的问题是:请求从哪里进来,哪些内容能交给模型,模型的结果怎样变成后端能消费的数据,用户下一轮提问怎样接上上下文,出了问题又怎么查回去。提示词只是这条链路中的一段,而且通常不是最容易出问题的一段。

这篇按我在 SmartKB 和 OpsPilot 里实际采用的边界来写。它们不是一个通用的“万能 AI 助手”方案:SmartKB 是带引用的知识库问答,OpsPilot 是只读的故障诊断。场景不同,但接口、约束、返回和保存的基本做法是相通的。

一次调用不是 question -> answer

一个能长期维护的 LLM 调用,至少要经过下面这些层次:

HTTP 请求
  -> 参数校验与身份边界
  -> 读取会话上下文
  -> 检索 / 工具调用 / 业务数据准备
  -> 组装提示词
  -> 调用模型
  -> 解析并校验模型输出
  -> 生成给前端的业务结果
  -> 保存消息、引用、调用记录与审计数据

这里有一个很重要的分工:模型负责在给定边界内生成或判断,业务系统负责决定能给它什么、能相信什么、最终保存什么。把这些都塞进一段 prompt,后面一定很难排查。

先定义接口合同,再接模型

前端不应该直接把整段历史和任意 prompt 发给模型服务。后端先收一个稳定的业务请求,例如知识库问答至少包含:

{
  "conversationId": "c-8f1c",
  "question": "退款规则里,哪些订单不能申请?",
  "metadataFilter": {
    "tenantId": "tenant-a",
    "documentType": "refund-policy"
  }
}

conversationId 用来读取有限的历史消息;metadataFilter 由服务端校验后参与检索,不能让用户自己指定任意数据库条件。问题文本是输入,不是指令。它会被放进 prompt,但不能覆盖系统约束。

SmartKB 的 Advanced RAG 调用在服务层接收的也是 question、元数据过滤条件和 conversationId。它先读取 PostgreSQL 中的最近会话,再做查询改写、混合召回、过滤和重排,最后才调用 ChatModel 生成答案。这样模型看到的是已经收窄过的资料,而不是整个知识库或整份聊天记录。

调用 LLM 接口时,配置和业务代码要分开

我倾向于把模型供应商的差异留在一个客户端适配层里,业务服务只依赖抽象的 ChatModelDiagnosisProvider

SmartKB 使用 Spring AI 的 OpenAI 兼容 ChatModel;OpsPilot 的 Python Agent 使用 OpenAI 兼容的 ChatOpenAI。下面这段是 OpsPilot 的调用方式,关键配置只有四个:密钥、网关地址、模型名和低温度参数。

client = ChatOpenAI(
    api_key=api_key,
    base_url=settings.model_base_url,
    model=settings.model_name,
    temperature=0.1,
)

密钥只从运行环境读取,不进入前端、不写入文章示例,也不提交到仓库。base_url 让同一套业务代码可以接 OpenAI 兼容的不同网关;modeltemperature 则必须随调用记录下来,否则同一个模型别名悄悄换了实现,后面很难解释结果差异。

在 SmartKB 里,业务代码只拿到注入的 ChatModel,调用点很短:

String answer = chatModel.call(new Prompt(prompt))
        .getResult()
        .getOutput()
        .getContent();

这一行短不代表事情简单。真正决定质量的是 prompt 来自哪些受控数据,以及拿到 answer 后还要补上哪些业务字段。

提示词应该由五块内容组成

我现在不把提示词理解成“让模型扮演一个角色”的文案,而是一次调用的输入合同。一个稳定的业务 prompt 通常拆成五块。

  1. 稳定规则:模型能做什么,不能做什么。它不随用户问题变化。
  2. 任务目标:这一轮到底是回答、分类、抽取、规划,还是生成工具参数。
  3. 可信上下文:检索片段、已校验的业务字段、允许调用的工具结果。
  4. 不可信输入:用户问题、工单标题、日志文本、文档原文。它们需要明确标记为数据,不能被当作指令执行。
  5. 输出合同:返回哪些字段、每个字段的范围、证据不足时应该返回什么。

SmartKB 的回答 prompt 很克制:只允许根据提供的文档片段回答;片段没有相关信息时,固定返回“文档中未找到相关信息”;回答使用中文。它不会要求模型补全常识,也不让模型自己扩展检索范围。

只根据以下文档内容回答用户问题,不要使用文档外的通用知识补充。
如果文档中没有相关信息,只回答:文档中未找到相关信息。

文档内容:
{retrieved_context}

用户问题:
{question}

请用中文回答:

上面看起来简单,但边界很明确:retrieved_context 是经过召回和重排后的可信材料,question 是待处理的数据,回答范围不能跨出前者。

OpsPilot 的约束更严格。故障标题、摘要和日志都可能包含攻击性的文本,所以它明确要求模型把这些字段当作不可信数据;模型只能从白名单中选择读取项,不能建议或执行重启、回滚、扩容、改配置等修复动作。这里的 prompt 不只是提高回答质量,也是防提示词注入和越权的第一道防线。

不要把自然语言直接当作后端返回

聊天类产品可以直接展示文本,但凡是后面还要做判断、调用工具、入库或进入审批的场景,都应该让模型返回结构化数据,并在服务端再校验一次。

OpsPilot 的诊断假设使用 Pydantic 模型约束。每一条假设都必须有置信度和支持它的证据 ID:

class Hypothesis(BaseModel):
    summary: str = Field(min_length=1, max_length=500)
    confidence: float = Field(ge=0, le=1)
    supporting_evidence_ids: list[str] = Field(min_length=1)
    contradicting_evidence_ids: list[str] = Field(default_factory=list)

class HypothesisSet(BaseModel):
    hypotheses: list[Hypothesis] = Field(max_length=3)

调用时使用结构化输出,并拒绝不符合 schema 的结果:

response = await client.with_structured_output(HypothesisSet).ainvoke(prompt)
if not isinstance(response, HypothesisSet):
    raise ProviderInvocationError("Model response did not match the required investigation schema")

这比在模型回答后用字符串切分 JSON 靠谱得多,但仍然不是终点。OpsPilot 还会在图里再次检查每个 supporting_evidence_ids 是否真在本轮证据集合中;不在就丢掉。模型输出只能是候选结论,证据和业务规则才是事实来源。

一次业务响应里应该有什么

给前端的响应不该只剩一个 content。以知识库问答为例,我会把“给人看的答案”和“给系统追溯的字段”一起返回:

{
  "answer": "根据退款规则,已发货且超过售后时限的订单不能申请。",
  "references": [
    {
      "documentId": "refund-policy-v3",
      "chunkId": "chunk-17",
      "title": "退款与售后规则"
    }
  ],
  "retrievedCount": 6,
  "confidence": 0.82,
  "refused": false,
  "reason": null,
  "traceId": "trace-..."
}

字段名可以不同,但语义不要丢:

  • answer 是展示内容,不等于系统事实。
  • references 让用户和开发者能回到原始资料。
  • confidencerefused 让“证据不足”成为正常业务状态,而不是让模型硬答。
  • retrievedCount、耗时和 trace 信息用于定位“没找到资料”“召回差”还是“模型生成慢”。

SmartKB 的 AdvancedRagResult 已经包含答案、改写后的查询、来源、引用片段、召回数量、置信度、拒答标记、拒答原因和分阶段耗时。它在低于置信度阈值时直接停止生成并返回拒答,而不是拿一个看起来流畅的答案掩盖检索不足。

会话不是把所有历史拼进 prompt

多轮对话要保存,但不能无限保存、无限拼接。SmartKB 的做法是把用户消息和助手消息写入 PostgreSQL,由 conversationId 关联;下一轮只读取最近有限条数作为查询改写的上下文。Redis 只做可失效缓存,不作为会话事实来源。

chatMemory.add(conversationId, new UserMessage(question));
chatMemory.add(conversationId, new AssistantMessage(result.answer()));

持久化消息至少应有:conversationId、顺序号、角色、内容、创建时间。若产品有引用展示或审计需求,还应保存引用 JSON 和关联的检索 trace。SmartKB 的领域模型为消息预留了 citationsJsontraceId,检索 trace 则单独保存查询、候选片段、检索模式和耗时。

这样做的好处不是“让 AI 记住更多”,而是能回答这些具体问题:用户上轮问了什么?这一轮用了哪些历史?最终答案引用了哪几个片段?检索链路在哪一步慢?服务重启后能不能继续对话?

要保存的不只是聊天记录

OpsPilot 的一次诊断会保存会话状态、证据、假设、工具调用审计、供应商和模型名。它还用 correlationId 做幂等,避免同一请求被重复写入。最终保存的数据不是模型原话,而是经过验证的诊断报告:

{
  "correlationId": "req-...",
  "status": "needs_more_evidence",
  "provider": "deepseek",
  "modelName": "configured-model",
  "toolCallCount": 4,
  "plannedReads": ["incident", "logs", "request-rate"],
  "evidence": [],
  "hypotheses": [],
  "toolCalls": []
}

这里最值得保留的是可复现信息:供应商、模型别名、温度、prompt 版本、工具版本、评测集版本、请求时间和关联 ID。模型服务会升级,模型别名也会变化;只保存一段答案,几天后就很难解释它为什么会这样回答。

我现在用的上线检查清单

在把一个 LLM 能力接进业务接口前,我至少会检查下面几项:

  • 输入是否有长度、租户、权限和敏感字段限制。
  • prompt 是否把固定规则、可信上下文和不可信输入分开。
  • 检索或工具结果是否有数量、时间范围和调用预算上限。
  • 返回是否能被 schema 校验,失败时是否有明确的降级或拒答状态。
  • 结论是否能回指到引用片段或证据 ID。
  • 会话、引用、trace、模型配置和错误是否能够关联查询。
  • 评测是否把检索质量和模型生成质量分开,避免用“回答看起来不错”掩盖问题。

提示词当然重要,但它不是独立存在的技巧。对业务系统来说,一段提示词真正有价值,是因为它被放在明确的输入合同、受控上下文、结构化输出和可追溯记录之间。把这几层搭起来以后,模型才不是一个碰运气的聊天框,而是能被验证、被约束、也能被维护的系统组件。

Brief

In a business system, an LLM call starts with an input contract and ends with a validated, traceable application result, not just a text completion.

The prompt should separate stable rules, trusted context, untrusted user input and an explicit output contract.

Persist messages, citations, model metadata and execution records so failures and regressions can be reproduced.