第1979篇:AI服务的南北向API设计——面向不同地区用户的接口差异化
第1979篇:AI服务的南北向API设计——面向不同地区用户的接口差异化
这篇聊一个不太常见但很实际的话题:当你的 AI 产品需要服务不同地区的用户时,API 设计需要怎么差异化?
"南北向"这个词借鉴自网络领域,这里我把它引申为:面向不同方向(不同地区、不同合规环境、不同用户群体)的 API 设计差异。
这不是理论问题,是我们团队把产品从国内扩展到海外市场时真实踩过的坑。
为什么需要差异化
先说清楚问题在哪里:
问题一:模型可用性不同。 国内用户用通义千问没问题,海外用户可能更熟悉 Claude 或 GPT,而且国内访问 OpenAI 有不稳定的问题,海外访问阿里云 DashScope 也可能有延迟。
问题二:数据合规要求不同。 中国用户的数据有出境限制,欧盟用户有 GDPR 要求,美国用户受 CCPA 约束。不同地区用户的数据,在 API 设计层面就要有不同处理。
问题三:内容策略不同。 同样的问题,在不同地区可能需要不同的内容规范、不同的安全过滤策略。
问题四:延迟和可用性。 全球同一个接入点,必然有的地区延迟高,有的地区延迟低。
地理感知架构
GeoDNS 是实现地理路由的基础设施层,阿里云 DNS、Cloudflare、AWS Route53 都支持。Java 应用层感知不到这层,接入请求到达的就是对应地区的网关。
地区识别与用户 Profile
在网关层,需要识别用户属于哪个区域,并建立区域 Profile:
@Component
public class GeoContextResolver {
@Autowired
private GeoIPDatabase geoIPDb;
@Autowired
private UserRegionRepository userRegionRepo;
/**
* 解析请求的地理上下文
*/
public GeoContext resolve(HttpServletRequest request, String userId) {
// 1. 先从用户账户取注册地区(最可信)
if (userId != null) {
Optional<UserRegion> saved = userRegionRepo.findByUserId(userId);
if (saved.isPresent()) {
return GeoContext.fromUserProfile(saved.get());
}
}
// 2. 从 IP 推断
String ip = extractClientIP(request);
GeoIPResult geoIP = geoIPDb.lookup(ip);
return GeoContext.builder()
.countryCode(geoIP.getCountryCode())
.region(geoIP.getRegion())
.isMainlandChina(geoIP.getCountryCode().equals("CN")
&& !isHongKongMacaoTaiwan(geoIP))
.isEU(EU_COUNTRIES.contains(geoIP.getCountryCode()))
.dataResidencyRequired(isDataResidencyRequired(geoIP))
.build();
}
private String extractClientIP(HttpServletRequest request) {
// 考虑反向代理的情况
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isEmpty()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private static final Set<String> EU_COUNTRIES = Set.of(
"DE", "FR", "IT", "ES", "NL", "BE", "AT", "SE", "DK", "FI",
"PT", "GR", "IE", "LU", "HR", "BG", "CZ", "EE", "HU", "LV",
"LT", "MT", "PL", "RO", "SK", "SI", "CY"
);
}区域模型选择策略
不同区域用不同的模型配置:
@Component
public class RegionalModelSelector {
@Autowired
private ModelAvailabilityChecker availabilityChecker;
/**
* 根据地理区域返回推荐的模型配置
*/
public ModelConfig selectForRegion(GeoContext geo, TaskType task) {
if (geo.isMainlandChina()) {
return selectForMainlandChina(task);
} else if (geo.isEU()) {
return selectForEU(task);
} else if (geo.isNorthAmerica()) {
return selectForNorthAmerica(task);
} else {
return selectDefault(task);
}
}
private ModelConfig selectForMainlandChina(TaskType task) {
return switch (task) {
case COMPLEX_REASONING -> ModelConfig.of("deepseek-reasoner", "https://api.deepseek.com");
case CODE_GENERATION -> ModelConfig.of("qwen-coder-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1");
default -> ModelConfig.of("qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1");
};
}
private ModelConfig selectForEU(TaskType task) {
// EU 要求数据留在欧洲,需要使用在欧洲部署的模型
// Azure OpenAI 在欧洲有节点,但如果有 GDPR 数据处理协议
// 通义千问目前没有欧洲节点,如果有合规需求则用欧洲本地部署
return switch (task) {
case COMPLEX_REASONING -> ModelConfig.of(
"gpt-4o",
"https://your-eu-azure-openai.openai.azure.com"
);
default -> ModelConfig.of(
"gpt-4o-mini",
"https://your-eu-azure-openai.openai.azure.com"
);
};
}
private ModelConfig selectForNorthAmerica(TaskType task) {
return switch (task) {
case COMPLEX_REASONING -> ModelConfig.of("gpt-4o", "https://api.openai.com");
case CREATIVE_WRITING -> ModelConfig.of("claude-3-5-sonnet", "https://api.anthropic.com");
default -> ModelConfig.of("gpt-4o-mini", "https://api.openai.com");
};
}
}差异化的 API 响应
不同地区的用户,响应格式和内容也可以有差异:
@RestController
@RequestMapping("/api/v1/chat")
public class RegionalChatController {
@Autowired
private GeoContextResolver geoResolver;
@Autowired
private RegionalChatService chatService;
@Autowired
private ContentFilterService contentFilter;
@PostMapping
public ResponseEntity<ChatResponse> chat(
@RequestBody ChatRequest request,
HttpServletRequest httpRequest,
@RequestHeader(value = "X-User-Id", required = false) String userId
) {
// 解析地理上下文
GeoContext geo = geoResolver.resolve(httpRequest, userId);
// 根据地区做内容过滤
String filteredMessage = contentFilter.filter(request.getMessage(), geo);
// 区域化处理
String rawResponse = chatService.chat(filteredMessage, geo);
// 构建区域化响应
ChatResponse response = buildRegionalResponse(rawResponse, geo);
return ResponseEntity.ok(response);
}
private ChatResponse buildRegionalResponse(String content, GeoContext geo) {
ChatResponse.Builder builder = ChatResponse.builder()
.content(content)
.modelUsed(/* 隐藏具体模型,只显示别名 */ "assistant");
// 国内用户附加服务信息
if (geo.isMainlandChina()) {
builder.serviceRegion("cn-east")
.dataStoredIn("中国大陆")
.supportContact("400-xxx-xxxx");
}
// EU 用户附加 GDPR 相关信息
if (geo.isEU()) {
builder.gdprCompliant(true)
.dataProcessingBasis("legitimate-interest")
.dataController("Your Company EU GmbH")
.dataRetentionDays(30);
}
return builder.build();
}
}内容过滤的区域差异化
@Service
public class ContentFilterService {
@Autowired
private ContentFilterConfig filterConfig;
public String filter(String content, GeoContext geo) {
FilterConfig config = filterConfig.getForRegion(geo.getCountryCode());
// 应用对应地区的过滤规则
return applyFilters(content, config.getFilterRules());
}
@Data
public static class FilterConfig {
private List<FilterRule> filterRules;
private List<String> blockedTopics;
private int maxResponseLength; // 不同地区可能有不同长度限制
private boolean requireDisclaimer; // 是否需要免责声明
}
}
// 内容过滤配置(不同地区不同规则)content-filter:
regions:
CN:
max-response-length: 8000
require-disclaimer: false
blocked-topics:
- "政治敏感话题"
- "违反中国法律法规的内容"
filter-rules:
- type: KEYWORD_FILTER
enabled: true
EU:
max-response-length: 10000
require-disclaimer: true
disclaimer: "本AI系统的回答仅供参考,不构成专业建议。"
filter-rules:
- type: PII_DETECTION # 个人信息检测(GDPR要求)
enabled: true
US:
max-response-length: 10000
require-disclaimer: false
filter-rules:
- type: HATE_SPEECH_FILTER
enabled: true数据主权:不同地区数据的物理隔离
这是南北向 API 设计里最重要的基础设施考量:
在 Spring Boot 中,通过多数据源和动态数据源路由实现数据主权:
@Configuration
public class RegionalDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.cn")
public DataSource cnDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.eu")
public DataSource euDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.us")
public DataSource usDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@Primary
public DataSource routingDataSource() {
RegionalRoutingDataSource routing = new RegionalRoutingDataSource();
routing.setTargetDataSources(Map.of(
"cn", cnDataSource(),
"eu", euDataSource(),
"us", usDataSource()
));
routing.setDefaultTargetDataSource(cnDataSource());
return routing;
}
}
// 动态数据源路由
public class RegionalRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return RegionalContext.getCurrentRegion(); // 从 ThreadLocal 取
}
}
// 在拦截器里设置当前地区
@Component
public class RegionContextInterceptor implements HandlerInterceptor {
@Autowired
private GeoContextResolver geoResolver;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
GeoContext geo = geoResolver.resolve(request, null);
RegionalContext.setCurrentRegion(geo.getDataRegion()); // "cn"/"eu"/"us"
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
RegionalContext.clear(); // 务必清理,防止内存泄漏
}
}多语言 Prompt 工程
不同语言用户的 Prompt 模板也需要本地化:
@Component
public class LocalizedPromptBuilder {
// Prompt 模板按语言存储
private final Map<String, Map<String, String>> promptTemplates = Map.of(
"zh-CN", Map.of(
"customer-service", "你是一位专业的客服代表,负责解答用户关于{product}的问题。请用礼貌、简洁的中文回答。",
"code-review", "你是一位资深Java工程师,请审查以下代码并指出问题:",
"summary", "请用简洁的中文总结以下内容的要点:"
),
"en", Map.of(
"customer-service", "You are a professional customer service representative for {product}. Please answer in a polite and concise manner.",
"code-review", "You are a senior Java engineer. Please review the following code and identify issues:",
"summary", "Please summarize the key points of the following content concisely:"
),
"ja", Map.of(
"customer-service", "あなたは{product}の専門カスタマーサービス担当者です。丁寧で簡潔な日本語で答えてください。",
"code-review", "あなたはシニアJavaエンジニアです。以下のコードをレビューして問題を指摘してください:",
"summary", "以下の内容の要点を簡潔にまとめてください:"
)
);
public String buildPrompt(String templateKey, String locale, Map<String, String> variables) {
String lang = locale.split("-")[0]; // "zh-CN" -> "zh", 先精确匹配
Map<String, String> templates = promptTemplates.getOrDefault(locale,
promptTemplates.getOrDefault(lang,
promptTemplates.get("en"))); // 默认英文
String template = templates.getOrDefault(templateKey,
promptTemplates.get("en").get(templateKey));
// 变量替换
String result = template;
for (Map.Entry<String, String> entry : variables.entrySet()) {
result = result.replace("{" + entry.getKey() + "}", entry.getValue());
}
return result;
}
}API 版本与地区兼容性
海外用户可能通过各种方式调用你的 API,版本兼容性要考虑地区差异:
@RestController
@RequestMapping("/api")
public class VersionedRegionalController {
/**
* v1: 国内版本,功能完整
*/
@PostMapping("/v1/chat")
public ResponseEntity<?> chatV1(
@RequestBody ChatRequest request,
HttpServletRequest httpRequest) {
GeoContext geo = geoResolver.resolve(httpRequest, null);
if (!geo.isMainlandChina()) {
// v1 是国内版本,海外用户引导到对应版本
return ResponseEntity.status(HttpStatus.PERMANENT_REDIRECT)
.header("Location", "/api/v1/global/chat")
.body(Map.of(
"message", "Please use the global API endpoint",
"redirect_to", "/api/v1/global/chat"
));
}
return ResponseEntity.ok(chatService.chat(request, geo));
}
/**
* global/v1: 全球版本
*/
@PostMapping("/v1/global/chat")
public ResponseEntity<?> chatGlobal(
@RequestBody ChatRequest request,
HttpServletRequest httpRequest) {
GeoContext geo = geoResolver.resolve(httpRequest, null);
return ResponseEntity.ok(chatService.chat(request, geo));
}
}限流:区域差异化策略
不同地区的限流策略可以不同(价格体系不同、资源分配不同):
@Component
public class RegionalRateLimiter {
@Autowired
private RedisTemplate<String, Long> redisTemplate;
// 不同地区的默认限流配置
private static final Map<String, RateLimitConfig> REGIONAL_LIMITS = Map.of(
"CN", new RateLimitConfig(60, 100000), // 60 QPS, 10万 tokens/天
"EU", new RateLimitConfig(30, 50000), // EU 合规成本高,限制稍低
"US", new RateLimitConfig(60, 100000),
"default", new RateLimitConfig(20, 30000)
);
public void checkRateLimit(String userId, GeoContext geo) throws RateLimitExceededException {
RateLimitConfig config = REGIONAL_LIMITS.getOrDefault(
geo.getCountryCode(),
REGIONAL_LIMITS.get("default")
);
// 滑动窗口限流
String key = "ratelimit:" + geo.getCountryCode() + ":" + userId + ":" +
Instant.now().getEpochSecond() / 60; // 按分钟
Long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(2));
if (count != null && count > config.getQpsPerMinute()) {
throw new RateLimitExceededException(
"Rate limit exceeded for region: " + geo.getCountryCode()
);
}
}
@Data
@AllArgsConstructor
public static class RateLimitConfig {
private int qpsPerMinute;
private int tokensPerDay;
}
}监控:区域维度的可观测性
@Aspect
@Component
public class RegionalMonitorAspect {
@Autowired
private MeterRegistry meterRegistry;
@Around("@annotation(regionMonitored)")
public Object monitor(ProceedingJoinPoint pjp,
RegionMonitored regionMonitored) throws Throwable {
GeoContext geo = RegionalContext.getCurrentGeo();
String region = geo != null ? geo.getCountryCode() : "UNKNOWN";
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
long duration = System.currentTimeMillis() - start;
meterRegistry.timer("api.request.duration",
Tags.of(
"region", region,
"endpoint", regionMonitored.endpoint(),
"status", "success"
)
).record(duration, TimeUnit.MILLISECONDS);
return result;
} catch (Exception e) {
meterRegistry.counter("api.request.errors",
Tags.of("region", region, "error", e.getClass().getSimpleName())
).increment();
throw e;
}
}
}通过 Grafana Dashboard 可以按地区维度查看:
- 各地区的请求量和错误率
- 各地区的 P99 延迟(帮助发现网络问题)
- 各地区的模型使用分布和成本
踩过的坑
坑一:GeoIP 库不准确。 商用 VPN 用户的 IP 定位经常出错,导致内容过滤策略应用错误。解决方案是优先用用户账户的注册地区,GeoIP 只作为补充。
坑二:EU 用户的 DPO 联系方式要在 API 响应里可获取。 GDPR 要求用户能方便地找到数据保护官(DPO)的联系方式,这个细节被我们第一版漏掉了,被法务部门发现后补上的。
坑三:模型在不同地区的 Endpoint 格式不同。 阿里云 DashScope 国内和国际 Endpoint 不同,配置写死了之后海外部署报错。最后用环境变量按部署地区注入。
坑四:时区问题影响限流计数。 按天计算的 token 配额,用的是服务器时间,结果不同时区的用户"每天"的重置时间不一样,有的用户抱怨额度重置时间奇怪。后来改成用用户本地时区的零点来重置。
小结
南北向 API 设计的核心是:认清不同地区用户的不同约束,在 API 层提前做好隔离,而不是事后打补丁。
三个最重要的维度:
- 数据主权——哪些数据存在哪里,严格物理隔离
- 模型可用性——当地能访问哪些模型,提前规划备选
- 内容合规——不同地区不同的内容规则,配置化管理
