news 2026/10/1 3:59:08

AI工程从零构建:可控性优先的MVA实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI工程从零构建:可控性优先的MVA实践指南

1. 这不是“搭积木”,而是亲手锻造AI系统的底层逻辑

“AI Engineering from Scratch”——看到这个标题,很多人第一反应是:“又要从零写Transformer?还是手推反向传播?”其实完全不是。我带过六支AI工程团队,做过从智能客服中台到工业缺陷检测平台的全栈交付,最深的体会是:真正的AI Engineering from Scratch,不是重造轮子,而是重建认知坐标系。它不追求代码行数的“从零开始”,而是在明确业务约束、数据边界、部署环境和运维成本的前提下,用最小可行架构(MVA)把模型、数据流、服务接口、监控链路全部拉通跑通。关键词“ai-engineering”和“from-scratch”合起来,本质是在说:拒绝黑盒调包,拒绝PPT架构图,拒绝把Colab Notebook当生产系统。它适合三类人:刚转岗的算法工程师想补工程短板,MLOps新手想搞懂pipeline怎么真正落地,还有技术负责人需要评估一个AI项目到底要多少“硬骨头”才能啃下来。这不是教你怎么调参,而是告诉你:当GPU显存报错、当线上推理延迟飙升300ms、当标注队列堆积2000张图没人审——你该先看哪一行日志、该查哪个指标、该改哪段代码。下面所有内容,都来自我在汽车零部件质检平台、连锁药店处方审核系统、城市管网巡检AI三个真实项目里,亲手拆过、重装过、半夜三点重启过的真实经验。

2. 为什么必须“From Scratch”?——被掩盖的工程断层真相

2.1 现成框架的甜蜜陷阱:它们解决的是“通用问题”,不是你的问题

我们团队去年接手一个老旧药房的处方合规性审核系统,原方案用Hugging Face Transformers + FastAPI搭了个微服务。表面看很现代:BERT微调、RESTful API、Docker容器化。但上线第三天就崩了——不是模型不准,是每天凌晨2点定时任务批量处理上万张处方扫描件时,内存泄漏导致整个Pod被K8s杀掉。运维同事查了一周,最后发现是Transformers库里Trainer类在evaluate()时默认缓存全部logits,而我们的batch_size设为128(为了吞吐),单次eval就占4.2GB显存。这根本不是模型问题,是框架抽象层对“批处理+内存受限”场景的彻底失语。

提示:Hugging Face的Trainer默认行为是为科研场景优化的——单次小批量、交互式调试、显存充足。但生产环境里,你得自己重写prediction_step(),禁用output_hidden_states,手动清空torch.cuda.empty_cache(),甚至把eval逻辑从Trainer里抽出来用纯PyTorch重写。这不是炫技,是生存必需。

再比如LangChain。我们给某制造企业做设备故障知识库问答,用LangChain Chain封装RAG流程。测试时QPS 120很稳,但真实产线接入后,用户提问带方言词(如“螺栓松了”说成“螺丝晃荡”),Embedding模型召回率断崖下跌。排查发现LangChain默认的SimilaritySearch没做query rewrite,也没接同义词扩展模块。而现成的HyDE或QueryClassifier插件,要么文档残缺,要么依赖版本冲突。最后我们砍掉整个Chain,用FAISS+Sentence-BERT原始API+自定义分词器重写检索层,耗时三天,但QPS提升到185,方言误召回率从37%压到6.2%。

这些不是框架不好,而是它们的设计哲学决定了:越“开箱即用”,越需要你提前预判所有可能的异常路径。而“From Scratch”的核心价值,就是逼你把每个抽象层的假设都摊开在阳光下——这个batch_size是谁定的?这个超时时间是按什么负载测的?这个重试机制覆盖了哪些网络错误码?

2.2 “Scratch”的真实含义:可控的最小闭环,而非代码行数归零

很多人误解“from scratch”等于“不用任何第三方库”。这是危险的。我在工业视觉项目里见过团队花两个月手写YOLOv5的NMS(非极大值抑制)算法,结果精度比OpenCV的cv2.dnn.NMSBoxes低1.8个点,还多出37ms延迟。真正的“Scratch”思维是:对每一行关键代码,你能说出它在当前系统里的不可替代性。

我们定义了一个“可控性四象限”来决策是否自研:

维度高可控性表现低可控性风险我们的裁决标准
可调试性日志能精确到tensor shape变化、梯度norm、GPU memory allocation只有“OOM”或“NaN loss”等模糊报错模型训练循环必须自研,哪怕只改三行
可观测性每个中间变量有明确metric埋点(如embedding cosine similarity分布)仅提供accuracy/loss两个标量数据预处理管道必须暴露所有transform耗时
可降级性故障时能切到规则引擎/兜底策略(如OCR失败时用正则匹配)全链路强依赖单一模型推理服务必须内置fallback路由开关
可审计性所有数据流向有trace_id贯穿,支持回溯任意样本的完整处理链路日志分散在不同服务,无法关联特征工程模块必须生成data lineage报告

按这个标准,“from scratch”在我们项目里实际表现为:

  • 模型层:用PyTorch Lightning重写训练脚本(保留Trainer的分布式能力,但替换fit()内部逻辑)
  • 数据层:自研StreamingDataset类,支持断点续传、样本去重、动态采样权重
  • 服务层:用Starlette(FastAPI底层)裸写ASGI app,不走@app.post装饰器,直接操作scope和receive对象
  • 监控层:用Prometheus Client Python直连指标,不通过任何中间agent

你看,我们依然用PyTorch、CUDA、Prometheus——但每一层的控制权都在自己手里。这才是“from scratch”的工程本质:不是拒绝工具,而是拒绝失控。

2.3 被忽略的隐性成本:数据管道才是真正的“Scratch起点”

90%的AI项目失败,死在数据上。而数据问题从来不是“数据少”,而是“数据不可信”。我们做城市管网AI巡检时,标注团队提供的10万张管道裂缝图,经抽样审计发现:

  • 32%的图片实际是水泥路面(标注员误判)
  • 17%的裂缝标注框覆盖了无关的钢筋网纹
  • 8%的图片EXIF信息显示拍摄于阴天,但标注要求“仅晴天有效”

如果直接喂给模型,F1-score最高卡在0.61。而现成的数据清洗工具(如Cleanlab、Snorkel)要么需要大量先验知识配置规则,要么在小样本下泛化极差。最后我们做的“from scratch”动作是:用OpenCV写了一个轻量级视觉验证器。

核心逻辑只有三步:

  1. 对每张图做HSV空间分割,提取灰度均值和饱和度方差(判断是否为金属管道)
  2. 用Canny边缘检测+霍夫变换,统计直线密度(排除水泥路面的网格状纹理)
  3. 对标注框内区域计算Laplacian方差,低于阈值则标记“模糊”(过滤阴天图)

这段代码217行,运行在标注平台后端,所有上传图片自动过筛。上线后,有效数据率从61%升到89%,模型收敛速度提升2.3倍。重点在于:这个验证器没有用任何ML模型,纯CV规则,但它的存在让整个数据飞轮转起来了——标注员收到实时反馈:“这张图疑似非管道,请确认”,错误率当天下降40%。

所以,“from scratch”的第一刀,永远该砍向数据入口。不是写模型,是建数据守门员。

3. 核心模块拆解:每个环节的“Scratch”实操细节

3.1 模型训练模块:如何用PyTorch Lightning实现“可调试性”

Lightning常被当成“高级Trainer”,但我们把它当“调试探针”用。关键改造点有三个:

第一,重写training_step()的返回结构
默认返回loss,但我们强制返回字典:

def training_step(self, batch, batch_idx): x, y = batch y_hat = self(x) loss = self.criterion(y_hat, y) # 关键:返回所有中间变量 return { 'loss': loss, 'logits': y_hat.detach(), 'targets': y.detach(), 'batch_size': len(x), 'grad_norm': torch.norm(torch.cat([p.grad.view(-1) for p in self.parameters() if p.grad is not None])) }

这样在on_train_batch_end()里就能做实时诊断:

def on_train_batch_end(self, outputs, batch, batch_idx): # 检测梯度爆炸 if outputs['grad_norm'] > 100: self.log('grad_norm_alert', outputs['grad_norm'], logger=True) # 触发梯度裁剪并记录告警 torch.nn.utils.clip_grad_norm_(self.parameters(), 1.0) # 检测logits异常分布 if torch.isnan(outputs['logits']).any(): self.log('nan_logits_count', 1, logger=True) # 保存当前batch用于复现 torch.save(batch, f'debug_nan_batch_{batch_idx}.pt')

第二,自定义configure_optimizers()的LR调度
不用torch.optim.lr_scheduler.ReduceLROnPlateau,因为它的patience参数在分布式训练下会因rank不同步导致学习率跳变。我们改用基于step的cosine decay,并注入epoch粒度的warmup:

def configure_optimizers(self): optimizer = torch.optim.AdamW(self.parameters(), lr=1e-4) # 自定义scheduler:warmup 10% epochs,然后cosine decay def lr_lambda(current_step): total_steps = self.trainer.max_steps warmup_steps = int(0.1 * total_steps) if current_step < warmup_steps: return float(current_step) / float(max(1, warmup_steps)) else: progress = float(current_step - warmup_steps) / float(max(1, total_steps - warmup_steps)) return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) return [optimizer], [scheduler]

第三,重写validation_step()规避内存陷阱
如前所述,绝不让Trainer自动缓存logits。我们手动控制:

def validation_step(self, batch, batch_idx): x, y = batch y_hat = self(x) # 只保留必要指标,立即释放显存 loss = self.criterion(y_hat, y) acc = (y_hat.argmax(dim=1) == y).float().mean() # 关键:detach后立刻del,不等GC del y_hat, x, y torch.cuda.empty_cache() return {'val_loss': loss, 'val_acc': acc}

这套改造后,我们在GPU A100上跑ResNet50训练,显存占用稳定在18.2GB(理论峰值24GB),而原生Lightning配置下波动在19.5~22.8GB之间。更重要的是,当出现NaN时,我们能精准定位到第几个batch、哪个layer的grad_norm异常——这才是“from scratch”带来的调试确定性。

3.2 数据管道模块:StreamingDataset的断点续传设计

工业场景的数据量动辄TB级,不可能全量加载。我们设计的StreamingDataset核心目标:任意时刻中断,恢复后从断点继续,且保证样本顺序不变。

关键设计有三点:

1. 分片索引文件(shard_index.json)
不是用glob遍历目录,而是预先生成JSON:

{ "shards": [ {"path": "/data/shard_001.tfrecord", "size": 12480, "start_offset": 0}, {"path": "/data/shard_002.tfrecord", "size": 13152, "start_offset": 12480}, {"path": "/data/shard_003.tfrecord", "size": 11904, "start_offset": 25632} ], "total_samples": 37536 }

start_offset是全局样本序号,不是文件偏移。这样即使shuffle,也能通过offset % shard_size定位到具体shard。

2. 带状态的迭代器(StatefulIterator)
普通__iter__无法保存状态,我们用__next__配合checkpoint:

class StatefulIterator: def __init__(self, dataset, resume_state=None): self.dataset = dataset self.state = resume_state or {'shard_idx': 0, 'sample_idx_in_shard': 0, 'global_sample_idx': 0} def __next__(self): # 从当前shard读取样本 sample = self.dataset.read_sample(self.state['shard_idx'], self.state['sample_idx_in_shard']) # 更新状态 self.state['sample_idx_in_shard'] += 1 self.state['global_sample_idx'] += 1 # 检查是否需切换shard if self.state['sample_idx_in_shard'] >= self.dataset.shard_sizes[self.state['shard_idx']]: self.state['shard_idx'] += 1 self.state['sample_idx_in_shard'] = 0 return sample def get_state(self): return self.state.copy()

3. Checkpoint持久化机制
每处理1000个样本,就把state写入checkpoint.pkl:

# 在DataLoader worker中 if global_step % 1000 == 0: with open('checkpoint.pkl', 'wb') as f: pickle.dump(iterator.get_state(), f)

恢复时:

iterator = StatefulIterator(dataset, resume_state=pickle.load(open('checkpoint.pkl', 'rb')))

这套设计让我们在电网设备红外图像训练中,遭遇三次意外断电(机房UPS故障),每次恢复后都能无缝续训,且最终模型精度与连续训练无差异(±0.03%)。而用PyTorch原生IterableDataset,断点续传需要重写整个__iter__逻辑,且无法保证shuffle一致性。

3.3 推理服务模块:Starlette裸写ASGI的性能压测实录

我们放弃FastAPI,用Starlette裸写ASGI app,核心诉求:精确控制每个字节的进出。

服务骨架如下:

from starlette.applications import Starlette from starlette.responses import JSONResponse, Response from starlette.routing import Route import asyncio import time # 全局模型实例(避免每次请求加载) model = load_model('/models/best.pt') async def inference(request): # 1. 严格限制请求体大小 if request.headers.get('content-length'): size = int(request.headers['content-length']) if size > 10 * 1024 * 1024: # 10MB return JSONResponse({'error': 'Payload too large'}, status_code=413) # 2. 同步读取body(避免await阻塞) body = await request.body() # 3. 图像解码(OpenCV比PIL快37%) nparr = np.frombuffer(body, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # 4. 模型推理(同步执行,避免GIL争抢) start_time = time.time() result = model.predict(img) infer_time = time.time() - start_time # 5. 构建响应(不走JSONResponse序列化,手动拼接) response_body = f'{{"result":{json.dumps(result)},"infer_time_ms":{infer_time*1000:.2f}}}'.encode('utf-8') return Response( content=response_body, media_type="application/json", headers={'X-Infer-Time': f'{infer_time*1000:.2f}ms'} ) routes = [ Route("/predict", endpoint=inference, methods=["POST"]), ] app = Starlette(routes=routes)

压测结果(AWS c5.4xlarge + T4 GPU):

并发数QPSP99延迟(ms)CPU使用率GPU显存占用
1684.211242%3.2GB
32156.713878%3.2GB
64189.321592%3.2GB

对比FastAPI同配置:

并发数QPSP99延迟(ms)CPU使用率GPU显存占用
1672.513551%3.2GB
32128.417285%3.2GB
64142.128998%3.2GB

差距主要在两点:

  • 序列化开销:FastAPI的JSONResponse会做完整schema校验和递归序列化,而我们手动拼接字符串,节省18ms
  • Body读取方式:Starlette的request.body()是同步IO,而FastAPI的await request.json()触发额外event loop调度,增加上下文切换

更关键的是,当出现恶意大payload攻击时,我们的服务在413错误后立即释放连接,而FastAPI会先读完全部body再校验,导致连接池耗尽。这就是“from scratch”带来的防御确定性。

3.4 监控告警模块:Prometheus指标的业务语义注入

很多团队用Prometheus只监控http_request_duration_seconds,但这对AI服务毫无意义。我们注入三层业务语义指标:

1. 模型层指标

  • model_inference_latency_seconds_bucket{model="crack_detector",le="0.1"}:按业务SLA分桶(0.1s是产线容忍上限)
  • model_prediction_confidence{model="crack_detector",class="crack"}:输出置信度分布直方图(用Histogram类型)

2. 数据层指标

  • data_drift_score{feature="crack_length_mm"}:用KS检验计算当前batch与baseline分布差异
  • label_consistency_rate{task="pipe_inspection"}:标注员间一致率(用Cohen's Kappa)

3. 服务层指标

  • fallback_trigger_count{reason="ocr_failed"}:兜底策略触发次数
  • cache_hit_ratio{cache="feature_store"}:特征缓存命中率

采集方式不是用prometheus_client的Counter,而是自研MetricCollector:

class MetricCollector: def __init__(self): self.metrics = {} def observe_inference(self, model_name, latency_ms, confidence): # 业务指标:置信度低于0.5的样本占比 if confidence < 0.5: self._inc(f'model_low_confidence_total{{model="{model_name}"}}') # SLA达标率 if latency_ms < 100: self._inc(f'model_sla_met_total{{model="{model_name}"}}') else: self._inc(f'model_sla_violated_total{{model="{model_name}"}}') def _inc(self, metric_key): if metric_key not in self.metrics: self.metrics[metric_key] = 0 self.metrics[metric_key] += 1 def export_prometheus(self): # 转换为Prometheus文本格式 lines = [] for key, value in self.metrics.items(): lines.append(f'{key} {value}') return '\n'.join(lines) + '\n'

然后在ASGI middleware中:

@app.middleware("http") async def metrics_middleware(request, call_next): start_time = time.time() response = await call_next(request) latency = time.time() - start_time # 注入业务指标 collector.observe_inference( model_name='crack_detector', latency_ms=latency*1000, confidence=getattr(response, 'confidence', 0.0) ) return response

这套设计让运维不再问“模型有没有挂”,而是问“今天有多少裂纹漏检(confidence<0.3)?哪些产线的图像质量在恶化(data_drift_score>0.15)?”。这才是AI工程监控该有的样子。

4. 实战踩坑:那些文档里绝不会写的“From Scratch”血泪教训

4.1 梯度检查点(Gradient Checkpointing)的隐形代价

为省显存开启torch.utils.checkpoint,结果模型精度掉1.2个点。查了三天才发现:Checkpointing会改变dropout的随机种子行为。PyTorch的Dropout在训练时用torch.random生成mask,而checkpoint的recompute过程会重置随机状态。

解决方案不是关掉checkpoint,而是显式固定dropout seed:

class FixedDropout(nn.Dropout): def __init__(self, p=0.5, inplace=False, seed=42): super().__init__(p, inplace) self.seed = seed def forward(self, input): if self.training: # 强制使用固定seed torch.manual_seed(self.seed + hash(str(input.shape)) % 10000) return F.dropout(input, self.p, True, self.inplace) return input

并在模型初始化时:

for name, module in self.named_modules(): if isinstance(module, nn.Dropout): module.__class__ = FixedDropout module.seed = 42

这个技巧让我们在A100上把ViT-Large显存从22GB压到14GB,精度损失控制在0.05%以内。

4.2 多GPU训练中的BatchNorm陷阱

用DistributedDataParallel时,SyncBatchNorm在跨节点同步时,如果某个GPU的batch size不足(最后一个batch),会导致all_reduce卡死。我们遇到过一次:8卡训练,第7卡因数据不均只剩2个样本,SyncBN等待其他卡的梯度,但第8卡已结束,死锁。

根治方法是在DataLoader里强制补齐:

def collate_fn(batch): # 获取最大长度 max_len = max(len(x[0]) for x in batch) # 补齐到max_len padded_batch = [] for x, y in batch: pad_len = max_len - len(x) if pad_len > 0: x = torch.cat([x, torch.zeros(pad_len, x.shape[1])]) padded_batch.append((x, y)) return default_collate(padded_batch)

同时在模型里加防护:

def forward(self, x): if x.size(0) < 2: # BN需要至少2个样本 x = torch.cat([x, x[:1]], dim=0) # 复制第一个样本 return self.bn(x)

4.3 Docker镜像里的CUDA版本幻觉

本地开发用CUDA 11.3,Dockerfile写FROM nvidia/cuda:11.3-devel-ubuntu20.04,但CI服务器的NVIDIA驱动是470.82,不兼容CUDA 11.3(要求>=470.18)。构建成功,运行时报libcudnn.so.8: cannot open shared object file。

终极解法:Docker镜像不指定CUDA版本,用nvidia/cuda:runtime基础镜像,运行时由宿主机驱动决定:

FROM nvidia/cuda:runtime-ubuntu20.04 # 安装PyTorch时指定cu113或cu117,根据CI环境变量 RUN pip install torch==1.12.1+cu113 -f https://download.pytorch.org/whl/torch_stable.html

CI脚本里:

# 检测宿主机CUDA版本 CUDA_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | cut -d'.' -f1,2) if [[ "$CUDA_VERSION" == "470.82" ]]; then pip install torch==1.12.1+cu113 elif [[ "$CUDA_VERSION" == "515.65.01" ]]; then pip install torch==1.12.1+cu116 fi

4.4 模型序列化的pickle安全漏洞

用torch.save(model.state_dict(), 'model.pth'),结果被注入恶意代码。攻击者提交一个特制pth文件,其中__reduce__方法执行os.system('rm -rf /')。

正确做法:永远不用torch.load()加载不可信文件,改用torch.jit.script导出:

# 训练后导出为TorchScript scripted_model = torch.jit.script(model) scripted_model.save('model.pt') # 加载时无需exec,绝对安全 loaded_model = torch.jit.load('model.pt')

TorchScript是序列化AST,不执行任意Python代码,且体积比state_dict小40%。

5. 工程决策树:什么情况下该“From Scratch”,什么该拥抱生态

5.1 必须“From Scratch”的5个红色信号

当你遇到以下任一情况,别犹豫,立刻启动“from scratch”模式:

  1. 业务SLA有硬性毫秒级要求
    如金融风控模型要求P99<50ms,而现成框架的序列化+中间件开销已占35ms。此时必须裸写ASGI,绕过所有抽象层。

  2. 数据合规性要求穿透式审计
    医疗影像AI需满足HIPAA,要求每张图的处理链路可追溯到原始DICOM文件的PatientID。现成ETL工具无法提供这种粒度的data lineage。

  3. 硬件资源极度受限
    边缘设备(Jetson Nano)只有4GB RAM,而Hugging Face的pipeline默认加载1.2GB模型权重。必须手写量化加载逻辑,逐层加载+释放。

  4. 故障定位需要tensor级可见性
    某次线上事故:模型在特定光照条件下输出全零。用现成trainer只能看到loss突增,而自研训练循环让我们发现是nn.BatchNorm2d在eval模式下未正确冻结running_mean。

  5. 需要与遗留系统深度耦合
    某钢厂的PLC控制系统只提供OPC UA协议,且要求推理结果以二进制帧格式返回。现成API网关无法解析OPC UA,必须用python-opcua库裸写协议适配层。

5.2 可以放心用生态的5个绿色场景

反之,以下场景强烈建议用成熟方案,别重复造轮子:

  1. 快速原型验证(PoC阶段)
    用Streamlit搭内部演示页,比从零写React快10倍,且业务方能直接改UI。

  2. 标准NLP任务(NER、情感分析)
    spaCy的en_core_web_sm在英文场景下F1比自研BiLSTM高2.3个点,且推理快3倍。

  3. 大规模分布式训练调度
    Kubeflow Pipelines的TFJob比手写K8s YAML可靠100倍,尤其处理worker故障自动重启。

  4. 模型版本管理
    MLflow Tracking比自建SQLite表更健壮,尤其处理并发写入和实验对比。

  5. A/B测试流量分配
    Argo Rollouts的canary发布比手写Nginx配置更精准,支持按用户ID哈希分流。

5.3 我的混合架构实践:Scratch与生态的黄金分割点

在最近的城市交通事件识别项目中,我们采用三级混合架构:

层级模块实现方式决策依据
核心层事件检测模型训练PyTorch Lightning自研需要梯度监控+动态采样+多尺度loss
管道层视频流解码FFmpeg C API裸调用OpenCV VideoCapture在RTSP流下丢帧率12%
服务层REST APIFastAPI业务方要求Swagger文档自动生成
数据层特征存储Feast需要与离线数仓实时同步,自研成本过高
监控层告警通知Prometheus + Alertmanager现成邮件/SMS集成,自研需对接运营商API

关键洞察:“From Scratch”不是全有或全无,而是对每个模块做可控性评估。我们花了两周把FFmpeg解码器嵌入Python,但只用了三天就集成Feast——因为前者影响推理准确性(不可妥协),后者只影响运维效率(可妥协)。

最后分享一个真实案例:我们曾为某快递公司做包裹面单OCR,初期用PaddleOCR,准确率92.4%。但客户投诉“圆通面单识别率只有83%”。查了三天才发现PaddleOCR的预训练数据里圆通样本不足0.3%。这时“from scratch”的价值就凸显了——我们没换框架,而是用Label Studio重标2000张圆通面单,用Detectron2重训文本检测头,再用CRNN重训识别头。最终圆通专项准确率升到96.7%,而整体框架仍是PaddleOCR。这才是工程师该有的务实精神:不为“从零”而从零,只为“可控”而可控。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:58:57

Python医学文本实体关系抽取:从数据预处理到模型训练全流程

简介&#xff1a;本资源为基于Python实现的中文医学文本实体关系抽取完整源码包&#xff0c;面向人工智能、自然语言处理方向的高校学生与开发者&#xff0c;尤其适合作为期末大作业、课程设计或入门级NLP项目实践参考。压缩包共13个文件&#xff0c;以12个Python源码文件为主&…

作者头像 李华
网站建设 2026/10/1 3:58:47

单片机控制板异常排查六步法:从供电到干扰逐步定位

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 3:58:42

Univer 在线表格实现指定单元格可编辑区域全攻略

去年做内部数据收集工具的时候&#xff0c;产品经理抛过来一个很刁钻的需求&#xff1a;给业务同事发一张表格&#xff0c;他们只能在指定的空白单元格里填数据&#xff0c;其他区域一个字都不能动。我当时第一反应是&#xff0c;这不就是 Excel 里的“允许用户编辑区域”吗&am…

作者头像 李华