从业十几年,总结出来一个“上线定律”:不管测试如何充分,上线时候总会发生一些你意想不到的甚至是很诡异的事情。
5月份团队开发了一个AI Agent,根据用户的输入,用大模型生成一份答案,结合自定义的一份文档模板给用户生成回复。
这个应用程序是用Springboot框架开发的,使用了freemarker模板引擎,java程序员应该都很熟悉。
你用过哪些java模板引擎呢?
2024-7-30日的早上,产品经理传递了一个紧急需求:你们这个Agent要支持英文:
- 用户用中文提问,使用中文回答,文档模板的话术也是中文
- 用户用英文提问,使用英文回答,文档模板的话术也是英文
时间只有一天,你们团队能实现吗?真的挺急的…,最好今天晚上就要上线。
这还不简单,我们的大模型本身就支持多语言;至于文档模板,写一个英文的就是,然后根据用户的问题语言类型,动态绑定就好。
09:15传递给团队,资深后端开发工程师xb说:问题不大,下午上线。
上午xb噼里啪啦的写代码,中午和xb吃饭时候,xb说已经自测通过了,下午制作docker镜像,申请上线。
“xb,你的速度挺快啊,话说那个英文模板你也写完了吗?”
“写完了,我把现在的中文模板给大模型,大模型给翻译了一个,效果很不错,我只做了简单的修改。”
一起吃饭的同事都对xb投去了赞许的目光。结合大模型研发的程序员效率就是高啊!
下午制作镜像,安全扫描,申请上线,一切都很丝滑。晚上19:30,上线申请还没审批完成,大家商量了下,明天一早再上线,先回家。
21:00,在操场和儿子跑步时候接到xb电话:
“扬哥,上线申请通过了?咱们是今晚上线,还是明天一早去公司操作?”
“产品经理咋说?”
“产品经理说不要晚于明天早上10点”
“咱们明天早点去公司?”
“要不还是今晚搞定吧,万一明天有个啥事”
“也对,咱们现在去公司吧。你先去,我等下去,Tech Leader 小亮就不去了,他家开车去公司得1小时,我让他远程会议接入吧”
21:40,我到公司的时候,xb已经在往生产环境传镜像了,挂着在线会议,不时的和线上的小亮讨论着。
“扬哥,其实没必要过来,我和小亮就搞定了,问题不大”
“没事,我住的不远,过来一起看下”
22:10,启动docker新镜像,开始测试。
case1: 用英文提问,给出了英文回答,使用了英文模板。
case2: 用中文提问,给出了中文回答,使用了英文模板。
WTF!!!
中文的回答,应该使用中文模板啊!
“xb, 你镜像替换的对不对啊?”
“应该是对的,不然之前不支持英文现在支持英文了啊”,“并且我本地测试是好的啊”
和xb一起 diff
代码,似乎也没啥问题。
之前的代码:
private Configuration configuration;
public FreemarkerPuzzle() {
initTemplate();
}
private void initTemplate() {
configuration = new Configuration(Configuration.getVersion());
configuration.setDefaultEncoding("utf-8");
File templatesDir = new File("/home/templates");
try {
configuration.setDirectoryForTemplateLoading(templatesDir);
} catch (IOException e) {
log.warn("Set template dir failed.", e);
}
}
public void generateResult() throws IOException {
Template template = configuration.getTemplate("summary_response_template.ftl");
Map<String, Object> fillData = buildFillData();
File resultDir = new File("/result");
if (resultDir.exists() || resultDir.mkdirs()) {
try (FileWriter writer = new FileWriter(new File(resultDir, "result" + System.currentTimeMillis() + ".json"))) {
template.process(fillData, writer);
} catch (TemplateException | IOException e) {
log.warn("Generate result failed.", e);
}
}
}
本次只是变更了获取freemarker
模板的代码:
public void generateResult2(boolean isChinese) throws IOException {
Template template = getTemplate(isChinese);
Map<String, Object> fillData = buildFillData();
File resultDir = new File("/result");
if (resultDir.exists() || resultDir.mkdirs()) {
try (FileWriter writer = new FileWriter(new File(resultDir, "result" + System.currentTimeMillis() + ".json"))) {
template.process(fillData, writer);
} catch (TemplateException | IOException e) {
log.warn("Generate result failed.", e);
}
}
}
public Template getTemplate(boolean isChinese) throws IOException {
if (isChinese) {
return configuration.getTemplate("summary_response_template.ftl");
}
return configuration.getTemplate("summary_response_template_en.ftl");
}
是的,中文模板名是summary_response_template.ftl
, 新增的英文模板名是summary_response_template_en.ftl
难道是判断中文的代码逻辑有问题?不应该啊,毕竟在本地测试是好的。
难道是提交代码时候,把中文的模板也写成英文的了?检测了代码提交,模板没有问题。
到底问题出在哪里??
这个时候已经是23:50分了,楼下的保安大叔上楼催了几次,
问:“你们几点回?”
“我们还不确定…”
"上线定律"再一次生效了。
"咱们的基础镜像是否不支持中文?"小亮在线上问
"不可能啊,咱们一直用的就是这个镜像,上个版本还是支持中文的"xb说
小亮的怀疑虽然不对,但是给了我灵感,莫非真和语言有关?
想到以前使用i18N
文件做国际化的经验,莫非这个freemarker
也有一套类似的文件加载规则?
打开加载freemarker
模板的源代码,比较复杂,短时间看不出个所以然,但是明显看到重载的方法里有Locale
类型的参数。
凌晨00:20,先不研究原理了,优先解决问题。
既然怀疑是类似国际化问题
,那么最简单的解决方案就是修改英文模板的文件名。
将英文模板文件名修改为new_summary_response_template.ftl
, 测试通过。问题**完美
**解决。
此时是凌晨00:40分,回家!
乘电梯下楼时候,xb说:“扬哥,今天你不白来”
其实,我也真不确定作为一名年近40的带团队的老程序员,这对我是不是一种褒奖。
中国的软件行业,35+的年龄就被企业各种嫌弃,找工作简历筛选很多都不一定能过。
市场上还流行一种说法,中国程序员的工作可替代性太强了,大部分程序都是CRUD的堆积,刚毕业的员工也能干。
确实,很多业务逻辑最后都会归于CRUD,但是从业十余年见过的程序员,脑子清楚且能把CRUD写明白的其实也凤毛麟角。
软件研发行业本质上还是吃人口的红利,代码质量低、bug多没关系,多安排几个测试;线上出了问题没关系,24小时on call修复;代码可重用性差没关系,多招几个人996。
言归正传,出于程序员的职业操守,遇到问题还是要搞明白原因。第二天过来按点上班,看下源码:
核心代码在:
// Find the template source
newLookupResult = lookupTemplate(name, locale, customLookupCondition);
@Override
public TemplateLookupResult lookupWithLocalizedThenAcquisitionStrategy(final String templateName,
final Locale templateLocale) throws IOException {
if (templateLocale == null) {
return lookupWithAcquisitionStrategy(templateName);
}
int lastDot = templateName.lastIndexOf('.');
String prefix = lastDot == -1 ? templateName : templateName.substring(0, lastDot);
String suffix = lastDot == -1 ? "" : templateName.substring(lastDot);
String localeName = LOCALE_PART_SEPARATOR + templateLocale.toString();
StringBuilder buf = new StringBuilder(templateName.length() + localeName.length());
buf.append(prefix);
tryLocaleNameVariations: while (true) {
buf.setLength(prefix.length());
String path = buf.append(localeName).append(suffix).toString();
TemplateLookupResult lookupResult = lookupWithAcquisitionStrategy(path);
if (lookupResult.isPositive()) {
return lookupResult;
}
int lastUnderscore = localeName.lastIndexOf('_');
if (lastUnderscore == -1) {
break tryLocaleNameVariations;
}
localeName = localeName.substring(0, lastUnderscore);
}
return createNegativeLookupResult();
}
关键就是这几行代码:
StringBuilder buf = new StringBuilder(templateName.length() + localeName.length());
我的中文模板名是summary_response_template.ftl
, freemarker
底层首先会把原始的模板名加上应用程序的Locale
信息变成summary_response_template_en_us.ftl
, 然后在你预设的模板路径下寻找文件名为summary_response_template_en_us.ftl
的模板,如果不存在,再寻找是否存在文件名为summary_response_template_en.ftl
的模板。
很不幸,我的应用程序的Locale信息设置的是en_US,同时还存在一个名称为summary_response_template_en.ftl
的文件模板。
至此,问题的原因是分析清楚了。
仔细想想,freemarker
这么设计是合理的,如果你的应用程序要支持更多的语言类型呢?不能每种语言类型都显式的加载一遍该语言的模板文件吧。
文章开头说的的上线定律
:不管测试如何充分,上线时候总会发生一些你意想不到的甚至是很诡异的事情,其实还有2个推论:
推论1:对生产环境永远要心存敬畏
推论2:不管理论分析的如何到位,真实环境测试不可或缺
若干年前,参加一次技术大会,腾讯的一位架构师分享他所在的部门采纳开源组件的要求:“如果你要在项目中引用一个开源组件,你必须阅读过这个开源组件90%以上的核心源代码”。
无法求证腾讯内部或者做技术分享的那位大神所在的部门是否真是这么做的,但是这么做确实很有必要,特别是质量要求很高的商用系统。这次的问题在线上出现,说明开发团队对freemarker
的实现机制还是欠缺了解。