Spring AI全球化:多语言AI应用开发的完整实践
2026/4/30大约 8 分钟
Spring AI全球化:多语言AI应用开发的完整实践
适读人群:需要做国际化AI应用的Java工程师,或对Spring AI国际化有兴趣的开发者 阅读时长:约15分钟
那个让我出糗的演示
去年参加一个技术交流会,我做了一个AI客服演示。台下有位同行问了一个问题,顺手用英文输入进去,结果系统给了一段日文回复——因为我在测试的时候把默认语言设成了日语,上线前忘了改回来。
全场都笑了。
那次经历让我意识到:多语言不是一个配置项,它是一套贯穿全局的设计。用户用什么语言提问,系统就应该用什么语言回答;Prompt的措辞风格要随语言调整;甚至错误提示、系统消息也要本地化。
这篇文章就把我们在做国际化AI应用时踩过的坑、总结出来的方案,完整写出来。
国际化AI应用面临的特殊挑战
普通Web应用做国际化,Spring的MessageSource就够用了。但AI应用有几个特殊问题:
| 挑战 | 普通国际化 | AI应用国际化 |
|---|---|---|
| 静态文本翻译 | MessageSource搞定 | 同左,简单 |
| Prompt模板本地化 | 无此概念 | 不同语言的Prompt措辞差异大 |
| LLM输出语言控制 | 不涉及 | 需要在Prompt中明确指定回复语言 |
| 用户语言检测 | HTTP Header | Header + 用户偏好 + 内容检测 |
| 多语言向量检索 | 不涉及 | 跨语言语义对齐是难题 |
最难处理的是最后两条。下面一一讲解。
整体架构
第一步:语言检测与上下文传递
语言检测要做得智能,不能只看Accept-Language:
@Component
public class LanguageDetector {
// 语言检测优先级:用户设置 > URL参数 > Header > 内容检测 > 默认
public Locale detectLocale(HttpServletRequest request, String userContent) {
// 1. 用户账户设置(最高优先级)
String userLang = getUserPreferredLanguage(request);
if (userLang != null) {
return Locale.forLanguageTag(userLang);
}
// 2. URL参数 ?lang=zh
String langParam = request.getParameter("lang");
if (StringUtils.hasText(langParam)) {
return Locale.forLanguageTag(langParam);
}
// 3. Accept-Language Header
String acceptLang = request.getHeader("Accept-Language");
if (StringUtils.hasText(acceptLang)) {
Locale headerLocale = parseAcceptLanguage(acceptLang);
if (headerLocale != null) return headerLocale;
}
// 4. 内容语言检测(用户输入了什么语言就回什么语言)
if (StringUtils.hasText(userContent)) {
return detectContentLanguage(userContent);
}
// 5. 默认中文
return Locale.SIMPLIFIED_CHINESE;
}
private Locale detectContentLanguage(String content) {
// 简单规则检测:检测字符范围
long chineseChars = content.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
long japaneseChars = content.chars()
.filter(c -> (c >= 0x3040 && c <= 0x309F) || (c >= 0x30A0 && c <= 0x30FF))
.count();
long latinChars = content.chars()
.filter(c -> c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
.count();
long total = content.length();
if (total == 0) return Locale.SIMPLIFIED_CHINESE;
if ((double) chineseChars / total > 0.3) return Locale.SIMPLIFIED_CHINESE;
if ((double) japaneseChars / total > 0.2) return Locale.JAPAN;
if ((double) latinChars / total > 0.5) return Locale.ENGLISH;
return Locale.SIMPLIFIED_CHINESE;
}
private String getUserPreferredLanguage(HttpServletRequest request) {
// 从JWT Token或Session中取用户偏好
String userId = extractUserId(request);
if (userId != null) {
return userPreferenceRepository.findLanguage(userId);
}
return null;
}
}用ThreadLocal在同一次请求内传递语言上下文:
@Component
public class LocaleContextHolder {
private static final ThreadLocal<Locale> LOCALE_HOLDER = new ThreadLocal<>();
public static void setLocale(Locale locale) {
LOCALE_HOLDER.set(locale);
}
public static Locale getLocale() {
Locale locale = LOCALE_HOLDER.get();
return locale != null ? locale : Locale.SIMPLIFIED_CHINESE;
}
public static void clear() {
LOCALE_HOLDER.remove();
}
}@Component
@Order(1)
public class LanguageFilter implements Filter {
private final LanguageDetector languageDetector;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 读取body用于内容检测(需要包装request)
String body = readBody(httpRequest);
Locale locale = languageDetector.detectLocale(httpRequest, body);
LocaleContextHolder.setLocale(locale);
try {
chain.doFilter(new BodyCachedRequest(httpRequest, body), response);
} finally {
LocaleContextHolder.clear();
}
}
}第二步:多语言Prompt模板管理
这是国际化AI应用最核心的部分。不同语言的Prompt不能只是翻译,还要考虑文化习惯:
目录结构:
src/main/resources/
prompts/
zh/
customer-service-system.st
rag-answer.st
summary.st
en/
customer-service-system.st
rag-answer.st
summary.st
ja/
customer-service-system.st中文 customer-service-system.st:
你是{companyName}的专业客服助理。
请用简洁、友好的中文回答用户问题。
回答要有条理,重点突出。
如果遇到无法解答的问题,请礼貌引导用户联系人工客服。英文 customer-service-system.st:
You are a professional customer service assistant for {companyName}.
Please respond in clear, friendly English.
Be concise and direct. Use bullet points when listing multiple items.
If you cannot answer a question, politely direct the user to contact human support.Prompt模板加载服务:
@Service
@Slf4j
public class LocalizedPromptService {
private final ResourceLoader resourceLoader;
// 缓存已加载的模板
private final Map<String, String> templateCache = new ConcurrentHashMap<>();
/**
* 根据当前语言环境加载Prompt模板
*/
public String loadPrompt(String templateName, Map<String, Object> variables) {
Locale locale = LocaleContextHolder.getLocale();
String lang = locale.getLanguage();
String templateContent = loadTemplateContent(templateName, lang);
return renderTemplate(templateContent, variables);
}
private String loadTemplateContent(String templateName, String lang) {
String cacheKey = lang + "/" + templateName;
return templateCache.computeIfAbsent(cacheKey, k -> {
// 先找精确语言版本
String content = tryLoadTemplate(lang, templateName);
if (content != null) return content;
// 找不到降级到默认语言(英文)
log.warn("未找到{}语言的{}模板,降级到英文", lang, templateName);
content = tryLoadTemplate("en", templateName);
if (content != null) return content;
// 最后降级到中文
content = tryLoadTemplate("zh", templateName);
if (content != null) return content;
throw new RuntimeException("找不到模板: " + templateName);
});
}
private String tryLoadTemplate(String lang, String templateName) {
String path = "classpath:prompts/" + lang + "/" + templateName + ".st";
try {
Resource resource = resourceLoader.getResource(path);
if (resource.exists()) {
return resource.getContentAsString(StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.debug("模板不存在: {}", path);
}
return null;
}
private String renderTemplate(String template, Map<String, Object> variables) {
ST st = new ST(template);
variables.forEach(st::add);
return st.render();
}
}第三步:语言指令注入
即使用了本地化Prompt,LLM有时还是会"漂移"到其他语言。需要在System Prompt里明确指定回复语言:
@Service
public class MultiLingualChatService {
private final ChatClient chatClient;
private final LocalizedPromptService promptService;
private final MessageSource messageSource;
public String chat(String userMessage, String sessionId) {
Locale locale = LocaleContextHolder.getLocale();
// 加载本地化系统Prompt
String systemPrompt = promptService.loadPrompt("customer-service-system",
Map.of("companyName", "TechCorp"));
// 注入语言强制指令
String languageInstruction = getLanguageInstruction(locale);
String fullSystemPrompt = systemPrompt + "\n\n" + languageInstruction;
return chatClient.prompt()
.system(fullSystemPrompt)
.user(userMessage)
.call()
.content();
}
private String getLanguageInstruction(Locale locale) {
Map<String, String> instructions = Map.of(
"zh", "重要:无论用户用什么语言提问,你必须始终用中文回复。",
"en", "Important: You MUST always respond in English, regardless of the language used in the question.",
"ja", "重要:ユーザーがどの言語で質問しても、必ず日本語で回答してください。",
"ko", "중요: 사용자가 어떤 언어로 질문하든 반드시 한국어로 답변하세요.",
"fr", "Important: Vous devez toujours répondre en français, quelle que soit la langue utilisée."
);
return instructions.getOrDefault(locale.getLanguage(),
"Important: Always respond in " + locale.getDisplayLanguage(Locale.ENGLISH) + ".");
}
}第四步:多语言向量检索
跨语言RAG是个硬骨头。用户用英文提问,但知识库是中文文档,语义向量差距很大。
解决思路:
推荐用多语言Embedding模型(如multilingual-e5-large),一步到位:
@Configuration
public class MultiLingualEmbeddingConfig {
@Bean
public EmbeddingModel multiLingualEmbeddingModel() {
// 使用支持100+语言的多语言embedding模型
// 这样中文文档和英文查询在同一个向量空间,可以直接比较
return new OllamaEmbeddingModel(
OllamaApi.builder().build(),
OllamaOptions.builder()
.model("mxbai-embed-large") // 支持多语言的embedding模型
.build()
);
}
}@Service
public class MultiLingualRagService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
private final LocalizedPromptService promptService;
public String ragAnswer(String question) {
Locale locale = LocaleContextHolder.getLocale();
// 使用多语言Embedding,直接检索(不需要翻译)
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(5));
// 构建检索上下文
String context = docs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
// 加载本地化的RAG回答Prompt
String systemPrompt = promptService.loadPrompt("rag-answer",
Map.of("language", locale.getDisplayLanguage(Locale.ENGLISH)));
return chatClient.prompt()
.system(systemPrompt)
.user(String.format("Context:\n%s\n\nQuestion: %s", context, question))
.call()
.content();
}
}配置与测试
application.yml配置:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
messages:
basename: i18n/messages
encoding: UTF-8
cache-duration: 3600
app:
i18n:
supported-locales: zh, en, ja, ko, fr
default-locale: zh
detect-from-content: true多语言集成测试:
@SpringBootTest
class MultiLingualChatServiceTest {
@Autowired
private MultiLingualChatService chatService;
@Test
void testChineseResponse() {
LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE);
String response = chatService.chat("你好,请介绍一下你们的产品", "test-session");
assertThat(response).matches(".*[\\u4E00-\\u9FFF].*"); // 包含中文字符
LocaleContextHolder.clear();
}
@Test
void testEnglishResponse() {
LocaleContextHolder.setLocale(Locale.ENGLISH);
String response = chatService.chat("Hello, please introduce your products", "test-session");
// 英文回复不应包含大量中文字符
long chineseCount = response.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
assertThat(chineseCount).isLessThan(5);
LocaleContextHolder.clear();
}
}常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| LLM回复语言"漂移" | 未显式指定语言 | System Prompt中加语言强制指令 |
| 跨语言检索召回率低 | 单语言Embedding | 换多语言Embedding模型 |
| 日文/韩文乱码 | 文件编码问题 | 所有配置文件统一UTF-8 |
| Prompt翻译失真 | 机器翻译Prompt | 请母语者重新撰写,不要翻译 |
| 用户语言检测不准 | 短文本特征少 | 结合用户账户设置提高准确率 |
小结
多语言AI应用的关键点:
- 语言检测要分层,用户设置 > URL参数 > Header > 内容检测
- Prompt要本地化撰写,不是翻译,不同语言的表达习惯差异大
- LLM语言指令要显式注入,不要依赖模型自己判断
- 跨语言检索用多语言Embedding,不要靠翻译中转
做好这四点,一个国际化的AI应用就有了坚实的语言基础。
