Design: LLM Output Contract 与 Prompt 管理方式

Current legacy contract snapshot(2026-07-15):本文的 typed metadata / schema / writer enforcement 分层仍有参考价值,但具体 taskDecisions[]typeCategory、global closeResultrecord_progresscreate_closed 示例只描述旧 contract,不是 Target。新 Task vocabulary、Task Policy 与 bounded reasoning loop 以 Task System Design V3 为准。

Date: 2026-06-23 Status: Draft Scope: contacts-analyzer prompt / schema / common metadata / future call-task-contact AI outputs


背景

我们现在在整理 contacts-analyzer 的 prompt。当前最大的问题不是某一句 prompt 写得不好,而是职责混在一起:

  • common 里有 taxonomy definitions。
  • contacts-analyzer 里有 ContactsAnalysisSchema
  • prompt 里又手写了一份 output contract reminder。
  • task playbook 有一份动态注入逻辑,但只覆盖 open task 场景。

这会导致 review 很痛苦:你不知道一句话到底是业务 policy、schema、taxonomy,还是为了弥补某个 writer 限制而写的 prompt hack。

最终目标是:schema / taxonomy / field metadata / writer behavior 有一个 typed source of truth,prompt 只是渲染这些 source of truth 的最小必要投影。

核心原则

  1. DB schema 管存储事实

    • 表、列、约束、index、enum storage value。
    • 不负责教模型怎么判断。
  2. common metadata 管 AI-facing meaning

    • 每个 enum value 是什么意思。
    • 每个 AI output field 是干什么的。
    • AI 是否能输出,写到哪里,什么时候适用。
    • writer 会不会 override 或 reject。
  3. Zod / JSON schema 管 output shape

    • required / optional / default / union / enum / pattern。
    • 交给 generateObject / provider structured output。
    • 不要再把完整 JSON schema 手写进 prompt。
  4. prompt policy 只管判断流程

    • 什么时候 create / close / create_closed / record_progress。
    • 什么证据可信。
    • 什么情况需要人工 review。
    • 不重复定义 enum value,不重复写 JSON shape。
  5. writer / Policy Guard 才有最终写入权

    • AI output 是 proposal。
    • DNC、store match、duplicate、stale proposal、hallucinated taskId 等由 deterministic code 拦。
    • prompt 里可以提醒,但不能把安全性主要寄托在提醒上。

最终分层

建议分成 4 层:

Layer 1: Common Typed Metadata
  enum definitions / field specs / action specs / applicability / playbook metadata

Layer 2: Output Schema and Validation
  Zod schema / JSON schema / structured output request body / parse validation

Layer 3: Authored Decision Policy
  human-written role, purpose, evidence rules, contact policy, task decision rules

Layer 4: Runtime Context and Writer Enforcement
  rendered user message, refs, open tasks, playbook snippets, writer, Policy Guard, DB constraints

模型真正收到的东西应该是:

输入面来自哪一层应该包含什么
system promptLayer 1 的精简渲染 + Layer 3role、purpose、evidence policy、少量 enum definitions、少量 decision rules
user promptLayer 4当前 contact/calls/messages/leads/open tasks/recently closed tasks/playbook snippets
request body schemaLayer 2Zod / JSON schema,不需要重复写成 markdown
post-processing/write pathLayer 4schema parse、ref resolve、Policy Guard、writer、DB constraints

Layer 1: Common Typed Metadata

这一层放什么

这一层应该在 @retaintive/common,因为它是多个 Lambda / prompt / UI / writer 都会用到的语义源头。

它应该包含:

  • enum values。
  • enum definitions。
  • AI 可用的 allowlist。
  • AI output field 的 ownership。
  • field 到 DB column 的 mapping。
  • field applicability。
  • task action metadata。
  • writer override / deterministic guard metadata。
  • playbook metadata 的短版决策说明。

这一层不应该包含:

  • 某个 Lambda 的完整 production prompt。
  • 某个模型专用的措辞。
  • 长篇 playbook 执行话术。
  • UI display copy。
  • DB query 或 writer SQL。

现在已有的例子

common 现在已经有这些基础:

// @retaintive/common/taxonomy/contact
CONTACT_LIFECYCLE_STAGE_DEFINITIONS
LEAD_STATUS_DEFINITIONS
PURCHASE_INTENT_DEFINITIONS

// @retaintive/common/taxonomy/task
TASK_TYPE_CATEGORY_DEFINITIONS
TASK_CLOSE_RESULT_DEFINITIONS
TASK_PROGRESS_TYPE_DEFINITIONS
TASK_CHANNEL_DEFINITIONS
AI_ASSIGNABLE_TASK_TYPE_CATEGORIES
AI_TASK_CLOSE_RESULTS

这些适合直接渲染成 prompt 中的 controlled vocabulary,例如:

lead_follow_up:
Lead is not booked yet and staff can still move them toward booking or the next sales conversation.

booked:
Lead confirmed an upcoming class, intro, or appointment; the booking objective is complete but membership conversion is not yet known.

建议新增的 metadata 例子

未来可以在 common 加一个 bounded context,比如:

src/ai-metadata/
  contacts-analysis/
    fields.ts
    task-actions.ts
    applicability.ts
    prompt-render.ts

字段 metadata 长这样:

export const contactAnalysisFieldSpecs = {
  customerSummary: {
    owner: 'ai',
    outputPath: 'customerSummary',
    dbTarget: { table: 'contacts', column: 'customer_summary' },
    valueKind: 'free_text',
    description: 'Staff-facing rolling summary of this customer.',
    required: true,
    maxGuidance: '3-5 concise sentences',
    writerBehavior: 'accepted_after_schema_parse',
  },

  lifecycleStage: {
    owner: 'ai',
    outputPath: 'lifecycleStage',
    dbTarget: { table: 'contacts', column: 'lifecycle_stage' },
    valueKind: 'enum',
    enumSource: 'CONTACT_LIFECYCLE_STAGE_DEFINITIONS',
    required: true,
    writerBehavior: 'accepted_after_schema_parse',
  },

  doNotContact: {
    owner: 'ai+staff',
    outputPath: 'doNotContact',
    dbTarget: { table: 'contacts', column: 'do_not_contact' },
    valueKind: 'boolean',
    required: false,
    preserveTrue: true,
    writerBehavior: 'staff_or_existing_true_wins',
    deterministicGuard: 'dnc_blocks_task_creation',
  },

  goals: {
    owner: 'ai',
    outputPath: 'goals',
    dbTarget: { table: 'contacts', column: 'goals' },
    valueKind: 'object_array',
    required: false,
    defaultValue: [],
    taxonomySource: null,
    note: 'No controlled vocabulary yet.',
  },
} as const;

task action metadata 长这样:

export const taskDecisionActionSpecs = {
  create_open: {
    aiEmittable: true,
    mapsToTaskAction: 'create_open',
    requiredFields: ['action', 'typeCategory', 'suggestedActions', 'reason'],
    optionalFields: ['sourceRefs'],
    forbiddenFields: ['taskId', 'closeResult', 'progressType', 'channel', 'nextDueAt'],
    typeCategorySource: 'AI_ASSIGNABLE_TASK_TYPE_CATEGORIES',
    writerGuards: ['dnc', 'duplicate', 'store_mismatch', 'low_confidence'],
  },

  close: {
    aiEmittable: true,
    mapsToTaskAction: 'close',
    requiredFields: ['action', 'taskId', 'typeCategory', 'closeResult', 'reason'],
    optionalFields: ['sourceRefs'],
    forbiddenFields: ['suggestedActions', 'progressType', 'channel', 'nextDueAt'],
    taskIdSource: 'OPEN TASKS taskRef only',
    closeResultSource: 'AI_TASK_CLOSE_RESULTS',
    writerGuards: ['task_not_open', 'store_mismatch', 'stale_proposal'],
  },

  reopen: {
    aiEmittable: false,
    mapsToTaskAction: 'reopen',
    reason: 'contacts-analyzer does not render closed task ids as mutable refs.',
  },
} as const;

playbook metadata 短版长这样:

export const taskDecisionPlaybookSpecs = {
  booked_not_converted: {
    decisionObjective:
      'Convert a completed intro/trial/class into a membership while the experience is fresh.',
    createWhen:
      'Customer completed or credibly attended intro/trial/class, has not reliably purchased, and staff still has a concrete next step.',
    closeWhen:
      'Customer purchased membership or clearly declined.',
    suggestedCloseResults: ['converted', 'not_interested', 'unable_to_reach'],
  },

  referral: {
    decisionObjective:
      'Capture a concrete referred person or complete referral benefit processing.',
    createWhen:
      'Customer offers or discusses a specific referral that staff can act on.',
    closeWhen:
      'Referred contact is captured, booked, visited, or referral benefit is processed.',
    suggestedCloseResults: ['referral_obtained', 'other'],
  },
} as const;

注意:这不是完整 staff playbook。完整 staff playbook 应该服务 task detail / playbook tab。contacts-analyzer 只需要短版,帮助它判断任务是否存在、是否完成、怎么 close。


Layer 2: Output Schema and Validation

这一层放什么

这一层定义模型必须返回什么形状。

它应该包含:

  • Zod schema。
  • discriminated union。
  • required / optional / default。
  • enum constraints。
  • string pattern,比如 T1 / C1 / M1 / L1
  • max array length 这类 sanity cap。
  • parse / validation retry。

这一层不应该包含:

  • 长篇业务判断规则。
  • enum value 的重复解释。
  • staff playbook。
  • evidence authority。

现在的例子

contacts-analyzer 当前已经有 ContactsAnalysisSchema,并且 invoke 层把它传给 generateObject

await generateObject({
  schema: zodSchema,
  mode: 'json',
  system: opts.systemPrompt,
  prompt: userMessage,
});

task decision union 现在大概是:

const TaskCreateOpenDecision = z.object({
  action: z.literal('create_open'),
  typeCategory: z.enum(AI_ASSIGNABLE_TASK_TYPE_CATEGORIES),
  suggestedActions: SuggestedActionsListSchema,
  sourceRefs: SourceRefsSchema,
  reason: z.string(),
});

const TaskCloseDecision = z.object({
  action: z.literal('close'),
  taskId: TaskRefSchema,
  typeCategory: z.enum(TASK_TYPE_CATEGORY),
  closeResult: z.enum(AI_TASK_CLOSE_RESULTS),
  sourceRefs: SourceRefsSchema,
  reason: z.string(),
});

这已经足够表达 JSON shape。prompt 里不应该再手写:

create_open requires A/B/C and forbids D/E/F

除非某个模型经常犯错,需要临时加一条极短提醒。

目标形态

短期:

// infra keeps final Lambda-owned schema
export const ContactsAnalysisSchema = z.object({
  customerSummary: z.string(),
  lifecycleStage: LifecycleStageEnum,
  taskDecisions: z.array(TaskDecision).max(20).default([]),
});

中期:

// common exports metadata, infra composes final Lambda-owned schema
export const ContactsAnalysisSchema = buildContactsAnalysisSchema({
  fieldSpecs: contactAnalysisFieldSpecs,
  actionSpecs: taskDecisionActionSpecs,
});

生产 prompt 只保留这一句:

Return only JSON matching the provided Zod schema. Do not include markdown or commentary.
Use only rendered refs such as T1, C1, M1, and L1. Never invent refs.

review/debug artifact 可以生成完整 contract:

DEBUG ONLY: Generated from ContactsAnalysisSchema
- customerSummary: required string
- lifecycleStage: required enum
- taskDecisions: array, max 20

但这份 debug artifact 不应该默认拼进 production system prompt。


Layer 3: Authored Decision Policy

这一层放什么

这一层是人写的判断规则。它告诉模型“什么时候用某个输出”,而不是“这个字段长什么样”。

它应该包含:

  • role。
  • overall purpose。
  • evidence authority。
  • contact profile 判断规则。
  • task decision 判断规则。
  • DNC / complaint / source evidence 的高层规则。
  • create_open / create_closed / close / update / record_progress 的选择逻辑。

这一层不应该包含:

  • enum value definitions。
  • task action JSON shape。
  • DB column mapping。
  • taskId regex。
  • 所有 lifecycle stage 的硬编码解释。

contact policy 例子

应该写:

先根据 evidence authority 判断这个人的当前 profile。
Current profile 是背景,不是绝对事实。
如果最新、明确、强证据说明 profile 已经变化,可以更新。
如果证据冲突或不够硬,不要编造确定结论,在 summary/reason 里说明 uncertainty。
不要编造 input 中没有的 pricing、contract、payment、attendance、staff promise。

不应该写:

lead means ...
member means ...
churned means ...

这些应该来自 common taxonomy definitions。

task decision policy 例子

应该写:

只有 staff 真的有下一步可以改变结果时,才 create_open。

如果最新 interaction 已经把业务目标完成了:
- 有 visible open task:close 那个 task。
- 没有 visible open task,但这个结果重要:create_closed 留 ledger。
- 不要再 create_open。

如果 staff 只是打了电话没人接、留了 voicemail、发了 text,而目标还没完成:
record_progress,不要 close 后再 create 一个同类 open task。

如果 input 没有显示可关闭的 open task,不要假装有 taskId。

不应该写:

create_open requires action/typeCategory/suggestedActions/reason and forbids taskId.

这是 schema/action metadata 的事。

evidence authority 例子

应该写:

判断证据时看 type、strength、freshness。
Intent / behavior 以最新明确互动为主。
Hard facts 以更强证据为主;没有 CRM 时,current profile 只是历史判断,不是绝对权威。
冲突不够硬时,不要静默覆盖,输出 uncertainty。

不应该在这里重复:

taskDecisions[] is max 20

这是 schema 的事。


Layer 4: Runtime Context and Writer Enforcement

这一层放什么

这一层是每次调用才知道的东西,以及模型输出后的 deterministic enforcement。

它应该包含:

  • rendered contact snapshot。
  • recent calls。
  • recent messages。
  • lead records。
  • open tasks。
  • recently closed tasks。
  • source refs,比如 C1 / M1 / L1
  • open task refs,比如 T1 / T2
  • 动态 playbook snippets。
  • schema parse。
  • ref resolution。
  • Policy Guard。
  • Task Orchestrator。
  • DB constraints。
  • audit / logs。

这一层不应该包含:

  • global enum definitions。
  • static role/purpose。
  • long policy prose。

user message 例子

CURRENT CONTACT SNAPSHOT:
- lifecycleStage: lead
- leadStatus: booked
- doNotContact: false
- customerSummary: Asked about intro class and booked Tuesday.

RECENT CALLS:
- C1 2026-06-23 inbound
  Customer said she completed the intro class and wants pricing for membership.
  Staff explained membership options and customer said she will sign up tomorrow.

OPEN TASKS:
- T1 [BOOKED_NOT_CONVERTED]
  Suggested: "Call after intro class to convert to membership"
  src: call 2026-06-20 (not shown)

PLAYBOOK GUIDANCE:
- [BOOKED_NOT_CONVERTED] Convert completed intro/trial/class into membership. Close converted only when purchase is reliable.

模型可能输出:

{
  "customerSummary": "Completed intro class and is actively considering membership pricing.",
  "lifecycleStage": "lead",
  "lifecycleState": "active",
  "leadStatus": "trialed",
  "purchaseIntent": "high",
  "taskDecisions": [
    {
      "action": "record_progress",
      "taskId": "T1",
      "typeCategory": "booked_not_converted",
      "progressType": "customer_considering",
      "channel": "phone",
      "sourceRefs": ["C1"],
      "reason": "Customer completed the intro class and is considering membership pricing, but has not purchased yet."
    }
  ]
}

writer 做的事:

1. Parse with ContactsAnalysisSchema.
2. Resolve taskId "T1" to real UUID from rendered OPEN TASKS map.
3. Resolve sourceRefs ["C1"] to real call/message/lead ids.
4. Convert proposal to common TaskAction.
5. Run applyTaskAction().
6. Policy Guard rejects if DNC/store mismatch/task_not_open/stale/duplicate.
7. Only accepted actions produce SQL writes.

create_closed 例子

user message:

RECENT CALLS:
- C1 2026-06-23 outbound
  Staff called a former member. Customer said she wants to return.
  Staff reactivated her membership during the call.

OPEN TASKS:
none

模型应该输出:

{
  "customerSummary": "Former member returned and was reactivated during the latest call.",
  "lifecycleStage": "member",
  "lifecycleState": "active",
  "leadStatus": "converted",
  "taskDecisions": [
    {
      "action": "create_closed",
      "typeCategory": "win_back",
      "closeResult": "win_back",
      "sourceRefs": ["C1"],
      "reason": "The win-back objective was opened and completed in the same call."
    }
  ]
}

为什么不是 create_open

业务目标已经完成。staff 没有下一步能改变结果。
但这是重要 revenue outcome,所以要留 closed ledger。

record_progress 例子

user message:

OPEN TASKS:
- T1 [LEAD_FOLLOW_UP]
  Suggested: "Call lead to book intro class"

RECENT CALLS:
- C1 outbound
  Staff called. No answer.

模型应该输出:

{
  "taskDecisions": [
    {
      "action": "record_progress",
      "taskId": "T1",
      "typeCategory": "lead_follow_up",
      "progressType": "no_answer",
      "channel": "phone",
      "sourceRefs": ["C1"],
      "reason": "Staff attempted the follow-up call but did not reach the lead."
    }
  ]
}

为什么不是 close

一次 no answer 只是过程事实,目标还没完成。

为什么不是 close 后 create 新 task:

同一个业务目标还在继续,用 progress event 更新已有 open task。

Task Playbook 应该怎么放

现在 task playbook 的问题是:它只根据 open tasks 的 typeCategory 动态注入 user message。也就是说:

  • 有 open task:模型能看到对应 guidance。
  • 没有 open task:模型创建新 task 时,看不到 category-specific playbook guidance。

建议目标态:

1. 决策短版 playbook 放 common metadata

用于 contacts-analyzer 判断:

export const taskDecisionPlaybookSpecs = {
  cancellation_risk: {
    objective: 'Save membership before cancellation/freeze/downgrade is final.',
    createWhen: 'Member expresses cancellation/freeze/downgrade risk and staff can still intervene.',
    progressWhen: 'Staff attempted save conversation but customer is still considering.',
    closeWhen: 'Customer stayed, cancellation completed, or customer clearly declined further help.',
    closeResults: ['cancel_saved', 'cancelled', 'not_interested', 'unable_to_reach'],
  },
} as const;

渲染给模型:

[CANCELLATION_RISK]
Create when member expresses cancellation/freeze/downgrade risk and staff can still intervene.
Close when saved or cancellation is clearly proceeding.
Use record_progress when staff attempted save work but outcome is still unresolved.

2. 执行长版 playbook 放 task playbook surface

用于 staff 打开 task detail 时看:

{
  "primaryAction": "Call the member and ask what is driving the cancellation request.",
  "recommendedSteps": [
    "Acknowledge the request.",
    "Ask one clarifying question about the reason.",
    "Offer approved save options."
  ],
  "avoid": [
    "Do not invent pricing.",
    "Do not promise policy exceptions."
  ],
  "closeGuidance": "Close cancel_saved only if the customer confirms they are staying."
}

3. prompt-builder 负责动态选择

user message 可以注入两类 playbook snippets:

OPEN TASK PLAYBOOK GUIDANCE:
- snippets for visible open tasks

CREATE CANDIDATE GUIDANCE:
- snippets for categories deterministically suggested by recent evidence

如果 deterministic pre-selection 不可靠,可以先渲染所有 AI-assignable category 的极短版 guidance。它们每条必须很短,否则 prompt 会被 playbook 淹没。


每个地方最终包含什么

位置最终应该包含不应该包含
@retaintive/common/db/schematable/column/enum storage/check constraintsprompt wording
@retaintive/common/taxonomyenum values, enum definitions, allowlistsLambda-specific output object schema
@retaintive/common/domainTaskAction, Policy Guard, Task Orchestrator, deterministic write behaviormodel prompt prose
@retaintive/common/ai-metadatafield specs, action specs, applicability, decision playbook metadatalong production prompt
lambda/contacts-analyzer/src/core/models.tsfinal Zod output schema while Lambda still owns itduplicated markdown output contract
lambda/contacts-analyzer/src/core/prompts/*.tsrole, purpose, evidence policy, decision policy, input context templateenum definitions or JSON shape copied by hand
lambda/contacts-analyzer/src/core/prompt-builder.tsruntime user message rendering and dynamic snippetsstatic taxonomy definitions
lambda/contacts-analyzer/src/infrastructure/neon-repository.tsmap parsed AI proposal to writer actions and persist accepted resultsmodel-facing instructions
docs/llm-input-review-packgenerated local review snapshotssource of truth

迁移计划

Phase 1: 先让 production prompt 不重复 schema

改动:

  • 删除或停用 output-contract-injected.ts 作为 production system prompt section。
  • output-contract.ts 只保留 debug/review renderer,或者先删掉。
  • system prompt 只保留一条短 output instruction。
  • snapshot test 更新。

目标效果:

Production prompt:
Return only JSON matching the provided Zod schema. Do not invent refs.

而不是:

create_open requires ...
create_closed requires ...
close requires ...

Phase 2: common 增加 AI metadata

改动:

  • 在 common 加 ai-metadata 或扩展 taxonomy bounded context。
  • 定义 contact output field specs。
  • 定义 task action specs。
  • 定义 task decision playbook specs。
  • 加 coverage tests,保证 metadata 覆盖 schema/taxonomy。

目标效果:

expectExactKeys(contactAnalysisFieldSpecs, ContactsAnalysisSchema.shape);
expectExactKeys(taskDecisionActionSpecs, TASK_ACTION_NAMES);

Phase 3: infra prompt renderer 从 common metadata 渲染

改动:

  • taxonomy sections 从 common definitions 渲染。
  • field applicability 从 common metadata 渲染。
  • action reminders 从 common metadata 渲染,但 production 只渲染最小版本。

目标效果:

GENERATED TASK VOCABULARY
- lead_follow_up: ...
- booked_not_converted: ...

TASK DECISION POLICY
- Create open only when staff has a real next step.
- Use create_closed for settled in-interaction revenue outcomes.

Phase 4: schema 从 metadata 生成或半生成

短期可以保留 Lambda-owned final schema,因为 common placement guide 当前就是这么要求的。

中期可以变成:

export const ContactsAnalysisSchema =
  buildContactsAnalysisSchema(contactAnalysisOutputSpec);

注意:即使 schema builder 在 common,具体某个 LLM call 的最终 output object 仍然可以由 consumer repo 组合。关键是不要手写第二份 field/action contract。

Phase 5: playbook 分层

改动:

  • decision short playbook 进入 common metadata 或 tenant pack。
  • staff execution long playbook 留在 task playbook surface。
  • prompt-builder 同时支持 open task snippets 和 create candidate snippets。

目标效果:

模型判断 task 用短 guidance。
staff 执行 task 用长 playbook。
两者来自同一份 category/objective metadata,不互相复制。

最终 review 方式

以后 review prompt 时,不应该只看一个大 system prompt。应该看四份东西:

  1. Authored policy review

    • role / purpose / evidence / task decision policy 是否像人话。
  2. Generated metadata review

    • common taxonomy / field metadata / action metadata 是否准确。
  3. Schema review

    • Zod schema 是否表达了 required/optional/union/default/ref pattern。
  4. Runtime context review

    • user message 是否把 contact/calls/messages/tasks/playbook refs 渲染清楚。

review pack 可以长这样:

00_runtime-parameters.md
01_system-authored-policy.md
02_generated-taxonomy-and-metadata.md
03_request-schema.json
04_user-message-example-redacted.md
05_writer-policy-guard-summary.md

这个 review pack 是生成物,不是 source of truth。


当前代码里的对应关系

Verified 2026-06-23 with rg / sed in this repo.

  • lambda/shared/utils/ai/invoke.ts 调用 generateObject({ schema: zodSchema, mode: 'json', system, prompt })
  • lambda/contacts-analyzer/src/core/models.ts 定义 ContactsAnalysisSchemaTaskDecision union。
  • lambda/contacts-analyzer/src/core/prompts/output-contract.ts 当前手写了 contact field notes 和 task action contract reminders。
  • lambda/contacts-analyzer/src/core/prompts/output-contract-injected.ts 当前把这些 reminders 作为 prompt section 导出。
  • lambda/contacts-analyzer/src/core/prompts/task/playbook-guidance.ts 当前按 open task category 提供短 playbook guidance。
  • lambda/contacts-analyzer/src/core/prompt-builder.ts 当前把 open task playbook guidance 插进 user message。
  • @retaintive/common/taxonomy/contact@retaintive/common/taxonomy/task 当前已有 enum definitions。
  • @retaintive/common/domain 当前已有 TaskAction / applyTaskAction / Policy Guard 方向。

外部参考: