forked from ChaiHao/BPOS-MVP
DeepSeek provider + 判据 ③ 机器路径跑通(零人工兜底)
Provider 侧:
· 新增 `provider-profile`(纯数据):各家的默认端点 / 结构化出参姿势 / 是否回推理
集中一处,adapter 只按画像行事 —— 新增 provider 只改画像,不写一族 if-else。
DeepSeek:`https://api.deepseek.com`,支持 json_object,v4 系列是推理模型。
· 建实例时 URL 可留空 → 取该 provider 的默认端点(openai-compatible 本就是
「随便哪个兼容端点」,没有默认,必须自填)。
· 推理正文归 `reasoning` 单列:那是**过程**不是结论,不进出参、不参与 schema 校验、
不入录制(录的是语义入参与结论)。
· 两档实例 deepseek-v4-flash / deepseek-v4-pro 已建;`quote-extractor` 是**用途名**,
指向 flash 档 —— 换档只改这一处绑定,活动一行不动。密钥只从环境读、落库即密封,
治理台只回「是否已绑定」(实测:库里是密文,视图无明文字段,三个实例探活全通)。
沿路修掉五处真问题:
① **同一任务被并发推进**:推进器与派发器各推各的,用的不是同一把锁 ——
实测同一步跑两遍、同一张卡发两张、同一行草稿写出两条互相打架的版本。
两者改用同一把 advisory lock(键的推导提取共用)。
② **LLM 步出参从不校验**:声明了 outputSchema,解析不出来就静默当 `{}` 写下去,
毛病要到下游缺字段时才炸、还炸在别处。改为「出参必过 schema」(AM 12.3),
不过即按声明的失败走向,并把模型原文记进留痕,好让人看见它到底回了什么。
③ **出参路径不认点号**:入参能走 `a.b.0.c`,出参只认扁平键 ——
模型按 schema 回嵌套结构(最常见的情形)就写出一条空记录。两边共用一份解析。
④ **归一步拿不到原文**:通道载荷只带指针不带正文、start→A1 边也没把主题正文传下去,
模型只拿到一个单号,于是回空 records。通道载荷带原文,边映射一路带到 A1。
⑤ **出参形状没讲给模型听**:声明了 schema 却只在提示里写「只输出 JSON」,
模型只能猜键名(实测回 `{"items":[]}` 而声明要 `{"records":[...]}`)。
改由网关按 outputSchema 统一附上说明 —— 这是系统的事,不该每个活动作者各写一遍。
另修:连接池没有 error 处理器,空闲连接被服务端掐断会让整个进程退出(一次运维操作
就能把 API 带崩)。
结果:一封 RFQ 入站 → 模型从正文抽出 pn/brand/eau/目标价/交期 →
D1 判定跳过补全 → 查历史价 → DMN 三分类 Type A → 验收放行卡,**全程零人工兜底**。
gates: tsc / eslint / depcruise(319 模块)/ api-report / 409 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/** Provider 画像 —— domain 测试零 mock(OS 02 系统管理) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LLM_PROVIDERS } from '@contracts';
|
||||
import { PROVIDER_PROFILES, profileOf, resolveBaseUrl } from '../internal/domain/provider-profile.ts';
|
||||
|
||||
describe('Provider 画像', () => {
|
||||
it('枚举里每个 provider 都有画像 —— 选了 provider 就该知道怎么调它', () => {
|
||||
for (const provider of LLM_PROVIDERS) expect(PROVIDER_PROFILES[provider]).toBeDefined();
|
||||
});
|
||||
|
||||
it('DeepSeek:默认端点 + 支持 json_object + 推理模型', () => {
|
||||
const deepseek = profileOf('deepseek');
|
||||
expect(deepseek.defaultBaseUrl).toBe('https://api.deepseek.com');
|
||||
expect(deepseek.structuredOutput).toBe('json-object');
|
||||
// v4 系列回包带 reasoning_content:那是过程不是结论,不能混进出参
|
||||
expect(deepseek.emitsReasoning).toBe(true);
|
||||
});
|
||||
|
||||
it('URL 留空回落到默认端点;填了就用填的', () => {
|
||||
expect(resolveBaseUrl('deepseek', undefined)).toBe('https://api.deepseek.com');
|
||||
expect(resolveBaseUrl('deepseek', ' ')).toBe('https://api.deepseek.com');
|
||||
expect(resolveBaseUrl('deepseek', 'https://proxy.internal/v1')).toBe('https://proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('openai-compatible 没有默认端点 —— 它本就是「随便哪个兼容端点」,必须自填', () => {
|
||||
expect(resolveBaseUrl('openai-compatible', undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,12 @@ export type ModelResult = {
|
||||
/** outputSchema 给定时的解析结果 */
|
||||
readonly object?: unknown;
|
||||
readonly usage: ModelUsage;
|
||||
/**
|
||||
* 推理模型的思考正文 —— **过程,不是结论**。
|
||||
* 出参只认 `text` / `object`;这一段单独留着,供任务记忆归档与断点复盘,
|
||||
* 不参与 schema 校验、不进业务字段。
|
||||
*/
|
||||
readonly reasoning?: string;
|
||||
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
||||
readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string };
|
||||
readonly mode: GatewayMode;
|
||||
@@ -85,6 +91,7 @@ export type UpsertLlmInstance = {
|
||||
readonly id?: string | undefined;
|
||||
readonly name: string;
|
||||
readonly provider: LlmProvider;
|
||||
/** 留空 = 取该 provider 的默认端点 */
|
||||
readonly baseUrl: string;
|
||||
readonly model: string;
|
||||
/** 只写不读:留空表示保持原值 */
|
||||
|
||||
@@ -7,34 +7,53 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import type { ProviderInvoker } from '../../port.ts';
|
||||
import { profileOf, schemaInstruction } from '../domain/provider-profile.ts';
|
||||
|
||||
/**
|
||||
* MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。
|
||||
* 枚举是为了治理台的呈现与日后分化,当下共用一个工厂。
|
||||
* 各家的差异(默认端点 / 结构化出参姿势 / 是否回推理)集中在 `provider-profile`,
|
||||
* 这里只按画像行事 —— 不写一族 if-else,新增 provider 只改画像。
|
||||
*/
|
||||
export function createProviderInvoker(): ProviderInvoker {
|
||||
return {
|
||||
async invoke({ binding, apiKey, call }) {
|
||||
const profile = profileOf(binding.provider);
|
||||
const provider = createOpenAICompatible({
|
||||
name: binding.provider,
|
||||
baseURL: binding.baseUrl,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// 端点支持就**直接要 JSON**,别指望从散文里捞;不支持的仍靠提示 + 提取兜底
|
||||
const wantsJson = call.outputSchema !== undefined;
|
||||
const useJsonMode = wantsJson && profile.structuredOutput === 'json-object';
|
||||
|
||||
// 出参形状讲给模型听:一处附上,所有步受益(活动作者不必各写一遍)
|
||||
const system = [call.system, wantsJson ? schemaInstruction(call.outputSchema!) : undefined]
|
||||
.filter((line): line is string => line !== undefined && line !== '')
|
||||
.join('\n\n');
|
||||
|
||||
const started = Date.now();
|
||||
const result = await generateText({
|
||||
model: provider(binding.model),
|
||||
...(call.system === undefined ? {} : { system: call.system }),
|
||||
...(system === '' ? {} : { system }),
|
||||
prompt: call.prompt,
|
||||
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
||||
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
||||
...(useJsonMode ? { providerOptions: { [binding.provider]: { response_format: { type: 'json_object' } } } } : {}),
|
||||
});
|
||||
|
||||
const object = call.outputSchema === undefined ? undefined : parseJsonLoose(result.text);
|
||||
/*
|
||||
* 推理模型的 `reasoning` 是**过程**不是结论:
|
||||
* 出参只认正文,推理另置为留痕素材 —— 混进出参会让 schema 校验对着思考过程报错。
|
||||
*/
|
||||
const reasoning = profile.emitsReasoning ? extractReasoning(result) : undefined;
|
||||
const object = wantsJson ? parseJsonLoose(result.text) : undefined;
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
...(object === undefined ? {} : { object }),
|
||||
...(reasoning === undefined || reasoning === '' ? {} : { reasoning }),
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
@@ -45,6 +64,16 @@ export function createProviderInvoker(): ProviderInvoker {
|
||||
};
|
||||
}
|
||||
|
||||
/** 推理正文:AI SDK 把它归到 reasoning 部件;拿不到就算了,不为它中断调用 */
|
||||
function extractReasoning(result: { reasoning?: unknown | undefined; reasoningText?: string | undefined }): string | undefined {
|
||||
if (typeof result.reasoningText === 'string') return result.reasoningText;
|
||||
if (!Array.isArray(result.reasoning)) return undefined;
|
||||
const parts = result.reasoning
|
||||
.map((r) => (typeof r === 'object' && r !== null && 'text' in r ? String((r as { text: unknown }).text) : ''))
|
||||
.filter((t) => t !== '');
|
||||
return parts.length === 0 ? undefined : parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化出参:模型常把 JSON 包在 ```json 围栏里。这里只做**提取**,
|
||||
* schema 校验归调用方(ADR-003:运行时校验用 ajv)。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Provider 画像 —— 纯数据、零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* 四元绑定里 Provider 是**枚举**,不是自由文本:选定 provider 就等于选定了它的
|
||||
* 默认端点与调用姿势。把这些差异集中在一处,adapter 只按画像行事、不写 if-else 家族。
|
||||
*
|
||||
* 「活动只见自定义模型名」——本文件的一切都在系统侧,活动既不触厂商也不触密钥(OS 02)。
|
||||
*/
|
||||
import type { LlmProvider } from '@contracts';
|
||||
|
||||
export type ProviderProfile = {
|
||||
/** 默认端点:治理台建实例时不填 URL 就用它 */
|
||||
readonly defaultBaseUrl: string;
|
||||
/**
|
||||
* 结构化出参的姿势:
|
||||
* `json-object` —— 端点支持 `response_format: {type:'json_object'}`,直接要 JSON;
|
||||
* `prompt-only` —— 端点不支持,只能在提示里要求并对文本做提取。
|
||||
*/
|
||||
readonly structuredOutput: 'json-object' | 'prompt-only';
|
||||
/** 推理模型:回包里带 reasoning,答案在正文,推理另置(不进出参、只作留痕) */
|
||||
readonly emitsReasoning: boolean;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* DeepSeek:OpenAI 兼容端点,支持 `json_object`;v4 系列是推理模型,
|
||||
* 回包带 `reasoning_content` —— 那是**过程**不是结论,不能混进出参。
|
||||
*/
|
||||
export const PROVIDER_PROFILES: Readonly<Record<LlmProvider, ProviderProfile>> = {
|
||||
'openai-compatible': {
|
||||
defaultBaseUrl: '',
|
||||
structuredOutput: 'prompt-only',
|
||||
emitsReasoning: false,
|
||||
label: 'OpenAI 兼容端点',
|
||||
},
|
||||
openai: {
|
||||
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
structuredOutput: 'json-object',
|
||||
emitsReasoning: false,
|
||||
label: 'OpenAI',
|
||||
},
|
||||
deepseek: {
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
structuredOutput: 'json-object',
|
||||
emitsReasoning: true,
|
||||
label: 'DeepSeek',
|
||||
},
|
||||
anthropic: {
|
||||
defaultBaseUrl: 'https://api.anthropic.com/v1',
|
||||
structuredOutput: 'prompt-only',
|
||||
emitsReasoning: false,
|
||||
label: 'Anthropic',
|
||||
},
|
||||
qwen: {
|
||||
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
structuredOutput: 'json-object',
|
||||
emitsReasoning: false,
|
||||
label: '通义千问',
|
||||
},
|
||||
};
|
||||
|
||||
export function profileOf(provider: LlmProvider): ProviderProfile {
|
||||
return PROVIDER_PROFILES[provider];
|
||||
}
|
||||
|
||||
/** 建实例时 URL 留空:回落到该 provider 的默认端点;openai-compatible 没有默认,必须自填 */
|
||||
export function resolveBaseUrl(provider: LlmProvider, given: string | undefined): string {
|
||||
const trimmed = (given ?? '').trim();
|
||||
return trimmed === '' ? profileOf(provider).defaultBaseUrl : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出参形状要**讲给模型听**。
|
||||
*
|
||||
* 声明了 `outputSchema` 却只在提示里写「只输出 JSON」,模型只能猜键名 ——
|
||||
* 实测 DeepSeek 会回 `{"items": []}` 而声明要的是 `{"records": [...]}`,
|
||||
* 于是每一次都判「出参不合 schema」。把 schema 附进提示是**系统的事**,
|
||||
* 不该让每个活动作者各写一遍、各写各的措辞。
|
||||
*
|
||||
* 只附结构(必填 + 属性),不附描述性长文:提示越短越稳。
|
||||
*/
|
||||
export function schemaInstruction(schema: Record<string, unknown>): string {
|
||||
return [
|
||||
'出参必须是**单个 JSON 对象**,且严格符合下面的 JSON Schema。',
|
||||
'键名照抄 schema,不得改写、不得增删顶层键;不确定的值留空或给合理默认,不要编造。',
|
||||
'不要输出 Markdown 围栏、不要输出解释文字。',
|
||||
`JSON Schema:\n${JSON.stringify(schema)}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { LlmInstanceView } from '@contracts';
|
||||
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
|
||||
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
|
||||
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
|
||||
import { resolveBaseUrl } from '../domain/provider-profile.ts';
|
||||
import type { ModelBinding } from '../domain/model-binding.ts';
|
||||
|
||||
export type LlmServiceDeps = {
|
||||
@@ -34,7 +35,9 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
|
||||
id: existing?.id ?? input.id ?? deps.ids.next(),
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
baseUrl: input.baseUrl,
|
||||
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
||||
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
||||
baseUrl: resolveBaseUrl(input.provider, input.baseUrl),
|
||||
model: input.model,
|
||||
apiKeyCipher,
|
||||
systemUse: input.systemUse,
|
||||
|
||||
@@ -71,6 +71,8 @@ export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
|
||||
return {
|
||||
text: raw.text,
|
||||
...(raw.object === undefined ? {} : { object: raw.object }),
|
||||
// 推理正文过网关但**不进录制**:录的是语义入参与结论,思考过程不是回放的判据
|
||||
...(raw.reasoning === undefined ? {} : { reasoning: raw.reasoning }),
|
||||
usage,
|
||||
binding: { name: binding.name, provider: binding.provider, model: binding.model },
|
||||
mode: deps.mode,
|
||||
|
||||
@@ -43,7 +43,7 @@ export type ProviderInvoker = {
|
||||
readonly binding: ModelBinding;
|
||||
readonly apiKey: string;
|
||||
readonly call: ModelCall;
|
||||
}): Promise<{ text: string; object?: unknown; usage: ModelUsage }>;
|
||||
}): Promise<{ text: string; object?: unknown; reasoning?: string; usage: ModelUsage }>;
|
||||
};
|
||||
|
||||
export type { ModelCall, ModelResult };
|
||||
|
||||
Reference in New Issue
Block a user